diff --git a/apple/Packages/TableProCore/Sources/TableProSSHTransport/SSHForwardFailure.swift b/apple/Packages/TableProCore/Sources/TableProSSHTransport/SSHForwardFailure.swift index 4b9953cd6c..d7422dd49c 100644 --- a/apple/Packages/TableProCore/Sources/TableProSSHTransport/SSHForwardFailure.swift +++ b/apple/Packages/TableProCore/Sources/TableProSSHTransport/SSHForwardFailure.swift @@ -1,16 +1,8 @@ -// -// SSHForwardFailure.swift -// TableProSSHTransport -// - import Foundation -/// Why a forwarding channel could not be opened, in terms the transport owns. -/// -/// The transport stops here rather than building the error the user reads: each app spells its -/// own `SSHTunnelError`, and a package target has no strings catalog to localize one from. The -/// destination travels with the reason because a refused unix socket and a refused TCP port send -/// the user to different settings on the server. +// Each app builds the error the user reads from this, since each spells its own SSHTunnelError. +// The destination travels with the reason because a refused unix socket and a refused TCP port +// send the user to different settings on the server. public enum SSHForwardFailure: Equatable, Sendable { case refused(destination: SSHForwardDestination, detail: String) case timedOut(destination: SSHForwardDestination, seconds: Int) diff --git a/apple/Packages/TableProCore/Sources/TableProSSHTransport/SSHNegotiationFailure.swift b/apple/Packages/TableProCore/Sources/TableProSSHTransport/SSHNegotiationFailure.swift index 2e9fb8e9ac..3216f6407c 100644 --- a/apple/Packages/TableProCore/Sources/TableProSSHTransport/SSHNegotiationFailure.swift +++ b/apple/Packages/TableProCore/Sources/TableProSSHTransport/SSHNegotiationFailure.swift @@ -1,8 +1,3 @@ -// -// SSHNegotiationFailure.swift -// TableProSSHTransport -// - import Foundation public struct SSHNegotiationFailure: Equatable, Sendable { @@ -43,6 +38,52 @@ public struct SSHNegotiationFailure: Equatable, Sendable { return software.isEmpty ? nil : String(software) } + public var explanation: String { + var sentences: [String] = [] + + switch stage { + case .keyExchangeOrHostKey: + sentences.append(String(localized: "TablePro and the SSH server have no key exchange or host key method in common.")) + case .cipher: + sentences.append(String(localized: "TablePro and the SSH server have no cipher in common.")) + case .mac: + sentences.append(String(localized: "TablePro and the SSH server have no MAC algorithm in common.")) + case .undetermined: + sentences.append(String(localized: "TablePro and the SSH server could not agree on how to encrypt the connection.")) + } + + if let serverSoftware { + sentences.append(String(format: String(localized: "The server runs %@."), serverSoftware)) + } + + if !supported.isEmpty { + let list = supported.formatted(.list(type: .and)) + switch stage { + case .keyExchangeOrHostKey: + sentences.append(String(format: String(localized: "TablePro supports these key exchange methods: %@."), list)) + case .cipher: + sentences.append(String(format: String(localized: "TablePro supports these ciphers: %@."), list)) + case .mac: + sentences.append(String(format: String(localized: "TablePro supports these MAC algorithms: %@."), list)) + case .undetermined: + break + } + } + + if !supportedHostKeys.isEmpty { + sentences.append(String( + format: String(localized: "It supports these host key types: %@."), + supportedHostKeys.formatted(.list(type: .and)) + )) + } + + return sentences.joined(separator: " ") + } + + public static var recoverySuggestion: String { + String(localized: "Ask the SSH server's administrator to turn on a method TablePro supports.") + } + public static func clientMethods(_ names: [String]) -> [String] { names.filter { !pseudoMethods.contains($0) } } diff --git a/apple/Packages/TableProCore/Tests/TableProSSHTransportTests/SSHNegotiationFailureTests.swift b/apple/Packages/TableProCore/Tests/TableProSSHTransportTests/SSHNegotiationFailureTests.swift index 2fb4109aa0..a6c12dbc5b 100644 --- a/apple/Packages/TableProCore/Tests/TableProSSHTransportTests/SSHNegotiationFailureTests.swift +++ b/apple/Packages/TableProCore/Tests/TableProSSHTransportTests/SSHNegotiationFailureTests.swift @@ -1,8 +1,3 @@ -// -// SSHNegotiationFailureTests.swift -// TableProSSHTransportTests -// - import Foundation import Testing @@ -83,4 +78,53 @@ struct SSHNegotiationFailureTests { #expect(failure.supported == ["curve25519-sha256"]) #expect(failure.supportedHostKeys == ["ssh-ed25519"]) } + + @Test( + "The explanation names the stage and what TablePro supports for it", + arguments: [ + (SSHNegotiationFailure.Stage.keyExchangeOrHostKey, "no key exchange or host key method in common", "key exchange methods"), + (.cipher, "no cipher in common", "these ciphers"), + (.mac, "no MAC algorithm in common", "these MAC algorithms"), + ] + ) + func explanationNamesTheStage(stage: SSHNegotiationFailure.Stage, mismatch: String, supportedList: String) { + let failure = SSHNegotiationFailure( + stage: stage, + serverSoftware: "OpenSSH_10.3", + supported: ["first-method", "second-method"], + supportedHostKeys: [] + ) + + #expect(failure.explanation.contains(mismatch)) + #expect(failure.explanation.contains("The server runs OpenSSH_10.3.")) + #expect(failure.explanation.contains(supportedList)) + #expect(failure.explanation.contains("first-method")) + #expect(failure.explanation.contains("second-method")) + } + + @Test("A key exchange failure also lists the host key types") + func explanationListsHostKeys() { + let failure = SSHNegotiationFailure( + stage: .keyExchangeOrHostKey, + serverSoftware: nil, + supported: ["curve25519-sha256"], + supportedHostKeys: ["ssh-ed25519", "rsa-sha2-512"] + ) + + #expect(failure.explanation.contains("host key types")) + #expect(failure.explanation.contains("rsa-sha2-512")) + #expect(!failure.explanation.contains("The server runs")) + } + + @Test("An undetermined stage says only that the encryption could not be agreed") + func explanationForUndeterminedStage() { + let failure = SSHNegotiationFailure( + stage: .undetermined, + serverSoftware: nil, + supported: ["ignored"], + supportedHostKeys: [] + ) + + #expect(failure.explanation == "TablePro and the SSH server could not agree on how to encrypt the connection.") + } } diff --git a/apple/TablePro/Core/SSH/LibSSH2Handshake.swift b/apple/TablePro/Core/SSH/LibSSH2Handshake.swift new file mode 100644 index 0000000000..f4ba630427 --- /dev/null +++ b/apple/TablePro/Core/SSH/LibSSH2Handshake.swift @@ -0,0 +1,227 @@ +// Compiled into the macOS app and, by file reference, into the iOS app, which defaults to +// MainActor isolation, so every top-level declaration states its own. + +import Foundation + +import CLibSSH2 +import TableProSSHTransport + +nonisolated enum LibSSH2Handshake { + enum Failure: Equatable, Sendable { + case negotiation(SSHNegotiationFailure) + case timedOut + case failed(String) + } + + // Reads what the failure needs before the caller frees the session, which drops what the + // negotiation agreed. + static func perform(_ session: OpaquePointer, socket: Int32) -> Failure? { + let watch = TransportWatch(session) + let rc = withExtendedLifetime(watch) { libssh2_session_handshake(session, socket) } + let end = watch.stop() + guard rc != 0 else { return nil } + + // libssh2_session_methods overwrites the last error when a method is missing, so it is read first. + let detail = lastError(session) + switch (rc, end) { + // libssh2 gives the same code when the connection ends before the server's KEXINIT. + case (LIBSSH2_ERROR_KEX_FAILURE, .disconnected(let message)?): + return .failed(String( + format: String(localized: "The SSH server ended the connection during the handshake: %@"), + message + )) + case (LIBSSH2_ERROR_KEX_FAILURE, .closed?): + return .failed(String(localized: "The SSH server closed the connection during the handshake.")) + case (LIBSSH2_ERROR_KEX_FAILURE, nil): + return .negotiation(negotiation(session)) + case (LIBSSH2_ERROR_TIMEOUT, _): + return .timedOut + default: + return .failed(detail ?? "libssh2 error \(rc)") + } + } + + private static func negotiation(_ session: OpaquePointer) -> SSHNegotiationFailure { + let stage = SSHNegotiationFailure.stage( + kex: method(session, LIBSSH2_METHOD_KEX), + cipher: agreed(session, LIBSSH2_METHOD_CRYPT_CS, LIBSSH2_METHOD_CRYPT_SC), + mac: agreed(session, LIBSSH2_METHOD_MAC_CS, LIBSSH2_METHOD_MAC_SC) + ) + + var supported: [String] = [] + var supportedHostKeys: [String] = [] + switch stage { + case .keyExchangeOrHostKey: + supported = SSHNegotiationFailure.clientMethods(algorithms(session, LIBSSH2_METHOD_KEX)) + supportedHostKeys = algorithms(session, LIBSSH2_METHOD_HOSTKEY) + case .cipher: + supported = algorithms(session, LIBSSH2_METHOD_CRYPT_CS) + case .mac: + supported = algorithms(session, LIBSSH2_METHOD_MAC_CS) + case .undetermined: + break + } + + return SSHNegotiationFailure( + stage: stage, + serverSoftware: SSHNegotiationFailure.serverSoftware(banner: banner(session)), + supported: supported, + supportedHostKeys: supportedHostKeys + ) + } + + // libssh2 agrees each direction on its own, so one side alone is not an agreement. + private static func agreed(_ session: OpaquePointer, _ clientToServer: Int32, _ serverToClient: Int32) -> String? { + guard let outgoing = method(session, clientToServer), method(session, serverToClient) != nil else { + return nil + } + return outgoing + } + + private static func method(_ session: OpaquePointer, _ type: Int32) -> String? { + guard let name = libssh2_session_methods(session, type) else { return nil } + return String(cString: name) + } + + private static func algorithms(_ session: OpaquePointer, _ type: Int32) -> [String] { + var names: UnsafeMutablePointer?>? + let count = libssh2_session_supported_algs(session, type, &names) + guard count > 0, let names else { return [] } + defer { libssh2_free(session, names) } + return (0 ..< Int(count)).compactMap { index in names[index].map { String(cString: $0) } } + } + + private static func banner(_ session: OpaquePointer) -> String? { + guard let banner = libssh2_session_banner_get(session) else { return nil } + return String(cString: banner) + } + + private static func lastError(_ session: OpaquePointer) -> String? { + var message: UnsafeMutablePointer? + var length: Int32 = 0 + libssh2_session_last_error(session, &message, &length, 0) + guard let message, length > 0 else { return nil } + return String(cString: message) + } +} + +private typealias LibSSH2Callback = @convention(c) () -> Void +private typealias LibSSH2Recv = @convention(c) ( + libssh2_socket_t, UnsafeMutableRawPointer?, Int, Int32, UnsafeMutablePointer? +) -> Int +private typealias LibSSH2Send = @convention(c) ( + libssh2_socket_t, UnsafeRawPointer?, Int, Int32, UnsafeMutablePointer? +) -> Int +private typealias LibSSH2Disconnect = @convention(c) ( + OpaquePointer?, Int32, UnsafePointer?, Int32, UnsafePointer?, Int32, + UnsafeMutablePointer? +) -> Void + +nonisolated private enum TransportEnd { + case closed + case disconnected(String) +} + +// Passes the handshake's socket reads and writes through to libssh2's own and notes an end of +// stream, a failed call or the server's DISCONNECT. The session's abstract pointer carries it, as +// keyboard-interactive does. +nonisolated private final class TransportWatch { + private let session: OpaquePointer + private let savedAbstract: UnsafeMutableRawPointer? + private let previousRecv: LibSSH2Callback? + private let previousSend: LibSSH2Callback? + private let previousDisconnect: LibSSH2Callback? + fileprivate let recv: LibSSH2Recv? + fileprivate let send: LibSSH2Send? + fileprivate var end: TransportEnd? + + init(_ session: OpaquePointer) { + self.session = session + let recvHook: LibSSH2Recv = watchedRecv + let sendHook: LibSSH2Send = watchedSend + let disconnectHook: LibSSH2Disconnect = watchedDisconnect + previousRecv = libssh2_session_callback_set2( + session, LIBSSH2_CALLBACK_RECV, unsafeBitCast(recvHook, to: LibSSH2Callback.self) + ) + previousSend = libssh2_session_callback_set2( + session, LIBSSH2_CALLBACK_SEND, unsafeBitCast(sendHook, to: LibSSH2Callback.self) + ) + previousDisconnect = libssh2_session_callback_set2( + session, LIBSSH2_CALLBACK_DISCONNECT, unsafeBitCast(disconnectHook, to: LibSSH2Callback.self) + ) + recv = previousRecv.map { unsafeBitCast($0, to: LibSSH2Recv.self) } + send = previousSend.map { unsafeBitCast($0, to: LibSSH2Send.self) } + let abstract = libssh2_session_abstract(session) + savedAbstract = abstract?.pointee + abstract?.pointee = Unmanaged.passUnretained(self).toOpaque() + } + + func stop() -> TransportEnd? { + libssh2_session_abstract(session)?.pointee = savedAbstract + libssh2_session_callback_set2(session, LIBSSH2_CALLBACK_RECV, previousRecv) + libssh2_session_callback_set2(session, LIBSSH2_CALLBACK_SEND, previousSend) + libssh2_session_callback_set2(session, LIBSSH2_CALLBACK_DISCONNECT, previousDisconnect) + return end + } + + fileprivate func noteClosed() { + if end == nil { end = .closed } + } + + fileprivate static func of(_ abstract: UnsafeMutablePointer?) -> TransportWatch? { + guard let pointer = abstract?.pointee else { return nil } + return Unmanaged.fromOpaque(pointer).takeUnretainedValue() + } +} + +nonisolated private func watchedRecv( + _ socket: libssh2_socket_t, + _ buffer: UnsafeMutableRawPointer?, + _ length: Int, + _ flags: Int32, + _ abstract: UnsafeMutablePointer? +) -> Int { + guard let watch = TransportWatch.of(abstract), let recv = watch.recv else { return -Int(EINVAL) } + let received = recv(socket, buffer, length, flags, abstract) + if received == 0 || (received < 0 && received != -Int(EAGAIN)) { + watch.noteClosed() + } + return received +} + +nonisolated private func watchedSend( + _ socket: libssh2_socket_t, + _ buffer: UnsafeRawPointer?, + _ length: Int, + _ flags: Int32, + _ abstract: UnsafeMutablePointer? +) -> Int { + guard let watch = TransportWatch.of(abstract), let send = watch.send else { return -Int(EINVAL) } + let sent = send(socket, buffer, length, flags, abstract) + if sent < 0 && sent != -Int(EAGAIN) { + watch.noteClosed() + } + return sent +} + +// The message is the server's own text, so it is cut to one line of printable characters. +nonisolated private func watchedDisconnect( + _ session: OpaquePointer?, + _ reason: Int32, + _ message: UnsafePointer?, + _ messageLength: Int32, + _ language: UnsafePointer?, + _ languageLength: Int32, + _ abstract: UnsafeMutablePointer? +) { + guard let watch = TransportWatch.of(abstract) else { return } + var text = "" + if let message, messageLength > 0, + let decoded = String(bytes: UnsafeRawBufferPointer(start: message, count: Int(messageLength)), encoding: .utf8) { + text = decoded.unicodeScalars + .map { CharacterSet.controlCharacters.contains($0) ? " " : String($0) } + .joined() + .trimmingCharacters(in: .whitespaces) + } + watch.end = .disconnected(text.isEmpty ? "reason \(reason)" : String(text.prefix(300))) +} diff --git a/apple/TablePro/Core/SSH/LibSSH2TunnelFactory.swift b/apple/TablePro/Core/SSH/LibSSH2TunnelFactory.swift index 10612e06f3..b9e969c43d 100644 --- a/apple/TablePro/Core/SSH/LibSSH2TunnelFactory.swift +++ b/apple/TablePro/Core/SSH/LibSSH2TunnelFactory.swift @@ -230,52 +230,47 @@ internal enum LibSSH2TunnelFactory { attempt: attempt ) + let firstJump = resolved.jumps.first?.target.originalHost do { - let session = try createSession( - socketFD: socketFD, - endpoint: firstEndpoint, - deadline: deadline, - attempt: attempt - ) + let session = try await onHop(firstJump) { + try createSession(socketFD: socketFD, endpoint: firstEndpoint, deadline: deadline, attempt: attempt) + } var jumpHops: [AuthenticatedChain.HopInfo] = [] var currentSession = session var currentSocketFD = socketFD do { - try await verifyHostKey( - session: session, - hostname: firstHop.host, - port: firstHop.port, - attempt: attempt - ) - - try authenticateFirstHop( - config: config, - credentials: credentials, - resolved: resolved, - session: session, - socketFD: socketFD, - endpoint: firstEndpoint, - deadline: deadline, - attempt: attempt - ) + try await onHop(firstJump) { + try await verifyHostKey(session: session, hostname: firstHop.host, port: firstHop.port, attempt: attempt) + try authenticateFirstHop( + config: config, + credentials: credentials, + resolved: resolved, + session: session, + socketFD: socketFD, + endpoint: firstEndpoint, + deadline: deadline, + attempt: attempt + ) + } if !resolvedJumps.isEmpty { for jumpIndex in 0..(_ jumpHost: String?, _ step: () async throws -> T) async throws -> T { + guard let jumpHost else { return try await step() } + do { + return try await step() + } catch let error as SSHTunnelError { + throw error.attributed(to: jumpHost) + } + } + internal struct ResolvedChainConfiguration { let primary: ResolvedSSHTarget let jumps: [ResolvedJump] @@ -606,7 +609,7 @@ internal enum LibSSH2TunnelFactory { // MARK: - Session - private static func createSession( + internal static func createSession( socketFD: Int32, endpoint: ConnectionTimeoutEndpoint, deadline: ConnectionDeadline, @@ -623,22 +626,17 @@ internal enum LibSSH2TunnelFactory { libssh2_session_set_blocking(session, 1) libssh2_session_set_timeout(session, max(1, deadline.remainingMilliseconds)) - let rc = libssh2_session_handshake(session, socketFD) - if rc != 0 { - var msgPtr: UnsafeMutablePointer? - var msgLen: Int32 = 0 - libssh2_session_last_error(session, &msgPtr, &msgLen, 0) - let detail = msgPtr.map { String(cString: $0) } ?? "Unknown error" + if let failure = LibSSH2Handshake.perform(session, socket: socketFD) { libssh2_session_free(session) - do { - try attempt.check(for: endpoint) - } catch { - throw error - } - if rc == LIBSSH2_ERROR_TIMEOUT { + try attempt.check(for: endpoint) + switch failure { + case .negotiation(let negotiation): + throw SSHTunnelError.negotiationFailed(negotiation, jumpHost: nil) + case .timedOut: throw deadline.timeoutError(for: endpoint) + case .failed(let detail): + throw SSHTunnelError.tunnelCreationFailed("SSH handshake failed: \(detail)") } - throw SSHTunnelError.tunnelCreationFailed("SSH handshake failed: \(detail)") } do { @@ -896,10 +894,11 @@ internal enum LibSSH2TunnelFactory { return } catch { // A wire-level rejection (or a partial success the server accepted as one - // factor of publickey,keyboard-interactive) reports AUTHENTICATION_FAILED. No - // passphrase can change that, so rethrow instead of prompting; any other errno - // means the local key file needs a passphrase we don't have yet. - guard libssh2_session_last_errno(session) != LIBSSH2_ERROR_AUTHENTICATION_FAILED else { + // factor of publickey,keyboard-interactive) reports AUTHENTICATION_FAILED, and a + // missing or unreadable file fails before libssh2 runs, leaving its errno stale. + // No passphrase can change either, so rethrow instead of prompting. + guard FileManager.default.isReadableFile(atPath: expandedPath), + libssh2_session_last_errno(session) != LIBSSH2_ERROR_AUTHENTICATION_FAILED else { throw error } } @@ -1124,7 +1123,8 @@ internal enum LibSSH2TunnelFactory { if libssh2_session_last_errno(session) == LIBSSH2_ERROR_TIMEOUT { throw deadline.timeoutError(for: endpoint) } - throw SSHTunnelError.channelOpenFailed + let reason = LibSSH2ForwardChannel.lastErrorMessage(session: session) + throw SSHTunnelError.tunnelCreationFailed("Could not open a channel to \(remoteHost):\(remotePort): \(reason)") } do { diff --git a/apple/TablePro/Core/SSH/SSHForwardFailure+SSHTunnelError.swift b/apple/TablePro/Core/SSH/SSHForwardFailure+SSHTunnelError.swift index 3b01079a4d..2e4f7715f3 100644 --- a/apple/TablePro/Core/SSH/SSHForwardFailure+SSHTunnelError.swift +++ b/apple/TablePro/Core/SSH/SSHForwardFailure+SSHTunnelError.swift @@ -1,16 +1,8 @@ -// -// SSHForwardFailure+SSHTunnelError.swift -// TablePro -// - import Foundation import TableProSSHTransport extension SSHForwardFailure { - /// The app's own error for a transport-level forwarding failure. The transport stops at - /// `SSHForwardFailure` because it has no strings catalog to localize from, and because a - /// refused unix socket and a refused TCP port send the user to different sshd settings. var tunnelError: SSHTunnelError { switch self { case .refused(let destination, let detail): diff --git a/apple/TablePro/Core/SSH/SSHTunnelError.swift b/apple/TablePro/Core/SSH/SSHTunnelError.swift index a7812bd7bb..a08315df84 100644 --- a/apple/TablePro/Core/SSH/SSHTunnelError.swift +++ b/apple/TablePro/Core/SSH/SSHTunnelError.swift @@ -1,8 +1,3 @@ -// -// SSHTunnelError.swift -// TablePro -// - import Foundation import TableProSSHConfig @@ -78,9 +73,11 @@ private extension AgentSocketOrigin { } } -/// Error types for SSH tunnel operations enum SSHTunnelError: Error, LocalizedError, Equatable, Sendable { case tunnelCreationFailed(String) + // jumpHost is nil when the SSH server itself failed. + case negotiationFailed(SSHNegotiationFailure, jumpHost: String?) + case jumpHostFailed(host: String, reason: String) case configExpansionFailed(String) case tunnelAlreadyExists(UUID) case noAvailablePort @@ -96,6 +93,11 @@ enum SSHTunnelError: Error, LocalizedError, Equatable, Sendable { switch self { case .tunnelCreationFailed(let message): return String(format: String(localized: "SSH tunnel creation failed: %@"), message) + case .negotiationFailed(let failure, let jumpHost): + guard let jumpHost else { return failure.explanation } + return String(format: String(localized: "Jump host %1$@ failed: %2$@"), jumpHost, failure.explanation) + case .jumpHostFailed(let host, let reason): + return String(format: String(localized: "Jump host %1$@ failed: %2$@"), host, reason) case .configExpansionFailed(let message): return message case .tunnelAlreadyExists(let id): @@ -219,10 +221,35 @@ enum SSHTunnelError: Error, LocalizedError, Equatable, Sendable { } extension SSHTunnelError { + var recoverySuggestion: String? { + guard case .negotiationFailed = self else { return nil } + return SSHNegotiationFailure.recoverySuggestion + } + var isUserCancelledAuthentication: Bool { guard case .authenticationFailed(reason: .cancelled) = self else { return false } return true } + + // The error a step on a jump host ends with, naming that hop as iOS does. A cancelled sign-in + // stays a cancellation, and an error that names its own host or follows a host key question + // keeps its words. + func attributed(to jumpHost: String) -> SSHTunnelError { + switch self { + case .negotiationFailed(let failure, nil): + return .negotiationFailed(failure, jumpHost: jumpHost) + case .authenticationFailed(reason: .cancelled): + return self + case .tunnelCreationFailed(let message): + return .jumpHostFailed(host: jumpHost, reason: message) + case .authenticationFailed, .channelOpenFailed: + return .jumpHostFailed(host: jumpHost, reason: errorDescription ?? "") + case .negotiationFailed, .jumpHostFailed, .configExpansionFailed, .tunnelAlreadyExists, .noAvailablePort, + .usernameMissing, .hostKeyVerificationFailed, .socketForwardingRefused, .forwardRefused, + .forwardTimedOut: + return self + } + } } extension SSHTunnelError: ConnectionPreflightReporting { @@ -230,8 +257,9 @@ extension SSHTunnelError: ConnectionPreflightReporting { switch self { case .configExpansionFailed, .tunnelAlreadyExists, .noAvailablePort, .usernameMissing: return true - case .tunnelCreationFailed, .authenticationFailed, .hostKeyVerificationFailed, .channelOpenFailed, - .socketForwardingRefused, .forwardRefused, .forwardTimedOut: + case .tunnelCreationFailed, .negotiationFailed, .jumpHostFailed, .authenticationFailed, + .hostKeyVerificationFailed, .channelOpenFailed, .socketForwardingRefused, .forwardRefused, + .forwardTimedOut: return false } } diff --git a/apple/TablePro/Resources/Localizable.xcstrings b/apple/TablePro/Resources/Localizable.xcstrings index f1c9fc0a9c..bd4a8d1e18 100644 --- a/apple/TablePro/Resources/Localizable.xcstrings +++ b/apple/TablePro/Resources/Localizable.xcstrings @@ -212,6 +212,10 @@ }, "Also drop the columns, functions and other objects that use this type" : { + }, + "Ask the SSH server's administrator to turn on a method TablePro supports." : { + "extractionState" : "manual", + "generatesSymbol" : false }, "At the End" : { @@ -368,9 +372,16 @@ }, "Inner Join" : { + }, + "It supports these host key types: %@." : { + "extractionState" : "manual", + "generatesSymbol" : false }, "Joins" : { + }, + "Jump host %1$@ failed: %2$@" : { + }, "KB" : { @@ -592,6 +603,34 @@ }, "Table Data" : { + }, + "TablePro and the SSH server could not agree on how to encrypt the connection." : { + "extractionState" : "manual", + "generatesSymbol" : false + }, + "TablePro and the SSH server have no cipher in common." : { + "extractionState" : "manual", + "generatesSymbol" : false + }, + "TablePro and the SSH server have no key exchange or host key method in common." : { + "extractionState" : "manual", + "generatesSymbol" : false + }, + "TablePro and the SSH server have no MAC algorithm in common." : { + "extractionState" : "manual", + "generatesSymbol" : false + }, + "TablePro supports these ciphers: %@." : { + "extractionState" : "manual", + "generatesSymbol" : false + }, + "TablePro supports these key exchange methods: %@." : { + "extractionState" : "manual", + "generatesSymbol" : false + }, + "TablePro supports these MAC algorithms: %@." : { + "extractionState" : "manual", + "generatesSymbol" : false }, "That connection no longer exists in TablePro." : { @@ -661,6 +700,16 @@ }, "The server refused a change. The changes before it were saved." : { + }, + "The server runs %@." : { + "extractionState" : "manual", + "generatesSymbol" : false + }, + "The SSH server closed the connection during the handshake." : { + + }, + "The SSH server ended the connection during the handshake: %@" : { + }, "The structure of a table in an attached database cannot be changed here." : { "extractionState" : "manual", diff --git a/apple/TableProMobile/TableProMobile/Helpers/ErrorClassifier+SSHTunnel.swift b/apple/TableProMobile/TableProMobile/Helpers/ErrorClassifier+SSHTunnel.swift index cafd103a2a..b81acb4282 100644 --- a/apple/TableProMobile/TableProMobile/Helpers/ErrorClassifier+SSHTunnel.swift +++ b/apple/TableProMobile/TableProMobile/Helpers/ErrorClassifier+SSHTunnel.swift @@ -1,4 +1,5 @@ import Foundation +import TableProSSHTransport // By case, because a translated message may hold none of the words the substring rules look for. // nil sends the error on to the text rules, which still give timeouts the Local Network recovery. @@ -6,10 +7,7 @@ nonisolated extension ErrorClassifier { static func sshTunnelFailure(_ error: SSHTunnelError) -> AppError? { switch error { case .negotiationFailed: - return tunnelFailed( - error, - recovery: String(localized: "Ask the SSH server's administrator to turn on a method TablePro supports.") - ) + return tunnelFailed(error, recovery: SSHNegotiationFailure.recoverySuggestion) case .usernameMissing: return tunnelFailed( error, diff --git a/apple/TableProMobile/TableProMobile/Localizable.xcstrings b/apple/TableProMobile/TableProMobile/Localizable.xcstrings index b64496ef73..e5563b1d2f 100644 --- a/apple/TableProMobile/TableProMobile/Localizable.xcstrings +++ b/apple/TableProMobile/TableProMobile/Localizable.xcstrings @@ -3173,7 +3173,8 @@ } }, "Ask the SSH server's administrator to turn on a method TablePro supports." : { - + "extractionState" : "manual", + "generatesSymbol" : false }, "Atom" : { "extractionState" : "manual", @@ -12315,7 +12316,8 @@ } }, "It supports these host key types: %@." : { - + "extractionState" : "manual", + "generatesSymbol" : false }, "Its connection is not imported." : { @@ -13273,6 +13275,12 @@ } } } + }, + "The SSH server closed the connection during the handshake." : { + + }, + "The SSH server ended the connection during the handshake: %@" : { + }, "TOTP" : { @@ -22426,16 +22434,20 @@ } }, "TablePro and the SSH server could not agree on how to encrypt the connection." : { - + "extractionState" : "manual", + "generatesSymbol" : false }, "TablePro and the SSH server have no cipher in common." : { - + "extractionState" : "manual", + "generatesSymbol" : false }, "TablePro and the SSH server have no key exchange or host key method in common." : { - + "extractionState" : "manual", + "generatesSymbol" : false }, "TablePro and the SSH server have no MAC algorithm in common." : { - + "extractionState" : "manual", + "generatesSymbol" : false }, "TablePro can't open “%@”." : { "localizations" : { @@ -22800,13 +22812,16 @@ "generatesSymbol" : false }, "TablePro supports these ciphers: %@." : { - + "extractionState" : "manual", + "generatesSymbol" : false }, "TablePro supports these key exchange methods: %@." : { - + "extractionState" : "manual", + "generatesSymbol" : false }, "TablePro supports these MAC algorithms: %@." : { - + "extractionState" : "manual", + "generatesSymbol" : false }, "TablePro will re-download every connection, group, and tag from your iCloud account. Local data on this device is not deleted." : { "extractionState" : "stale", @@ -25331,7 +25346,8 @@ } }, "The server runs %@." : { - + "extractionState" : "manual", + "generatesSymbol" : false }, "The server sent an unexpected message and the connection was reset. Run the query again." : { "extractionState" : "manual", diff --git a/apple/TableProMobile/TableProMobile/SSH/LibSSH2HandshakeFailure.swift b/apple/TableProMobile/TableProMobile/SSH/LibSSH2HandshakeFailure.swift deleted file mode 100644 index 723789426f..0000000000 --- a/apple/TableProMobile/TableProMobile/SSH/LibSSH2HandshakeFailure.swift +++ /dev/null @@ -1,85 +0,0 @@ -import CLibSSH2 -import Foundation -import TableProSSHTransport - -// Runs on the failed session before libssh2_session_free, which drops what the negotiation agreed. -nonisolated enum LibSSH2HandshakeFailure { - static func error(session: OpaquePointer, rc: Int32) -> SSHTunnelError { - // libssh2_session_methods overwrites the last error when a method is missing, so it is read first. - let detail = lastError(session) - - switch rc { - case LIBSSH2_ERROR_KEX_FAILURE: - return .negotiationFailed(negotiation(session)) - case LIBSSH2_ERROR_TIMEOUT: - // libssh2 says "Timed out waiting on socket", which reads as blocked Local Network access, - // but the TCP connect already succeeded. - return .handshakeFailed(String(localized: "The SSH server did not answer in time.")) - default: - return .handshakeFailed(detail ?? "Handshake failed (error \(rc))") - } - } - - private static func negotiation(_ session: OpaquePointer) -> SSHNegotiationFailure { - let stage = SSHNegotiationFailure.stage( - kex: method(session, LIBSSH2_METHOD_KEX), - cipher: agreed(session, LIBSSH2_METHOD_CRYPT_CS, LIBSSH2_METHOD_CRYPT_SC), - mac: agreed(session, LIBSSH2_METHOD_MAC_CS, LIBSSH2_METHOD_MAC_SC) - ) - - var supported: [String] = [] - var supportedHostKeys: [String] = [] - switch stage { - case .keyExchangeOrHostKey: - supported = SSHNegotiationFailure.clientMethods(algorithms(session, LIBSSH2_METHOD_KEX)) - supportedHostKeys = algorithms(session, LIBSSH2_METHOD_HOSTKEY) - case .cipher: - supported = algorithms(session, LIBSSH2_METHOD_CRYPT_CS) - case .mac: - supported = algorithms(session, LIBSSH2_METHOD_MAC_CS) - case .undetermined: - break - } - - return SSHNegotiationFailure( - stage: stage, - serverSoftware: SSHNegotiationFailure.serverSoftware(banner: banner(session)), - supported: supported, - supportedHostKeys: supportedHostKeys - ) - } - - // libssh2 agrees each direction on its own, so one side alone is not an agreement. - private static func agreed(_ session: OpaquePointer, _ clientToServer: Int32, _ serverToClient: Int32) -> String? { - guard let outgoing = method(session, clientToServer), method(session, serverToClient) != nil else { - return nil - } - return outgoing - } - - private static func method(_ session: OpaquePointer, _ type: Int32) -> String? { - guard let name = libssh2_session_methods(session, type) else { return nil } - return String(cString: name) - } - - private static func algorithms(_ session: OpaquePointer, _ type: Int32) -> [String] { - var names: UnsafeMutablePointer?>? - let count = libssh2_session_supported_algs(session, type, &names) - guard count > 0, let names else { return [] } - defer { libssh2_free(session, names) } - return (0 ..< Int(count)).compactMap { index in names[index].map { String(cString: $0) } } - } - - private static func banner(_ session: OpaquePointer) -> String? { - guard let banner = libssh2_session_banner_get(session) else { return nil } - return String(cString: banner) - } - - private static func lastError(_ session: OpaquePointer) -> String? { - var message: UnsafeMutablePointer? - var length: Int32 = 0 - libssh2_session_last_error(session, &message, &length, 0) - guard let message, length > 0 else { return nil } - return String(cString: message) - } -} diff --git a/apple/TableProMobile/TableProMobile/SSH/SSHTunnelError.swift b/apple/TableProMobile/TableProMobile/SSH/SSHTunnelError.swift index 381f36b74d..5851db493e 100644 --- a/apple/TableProMobile/TableProMobile/SSH/SSHTunnelError.swift +++ b/apple/TableProMobile/TableProMobile/SSH/SSHTunnelError.swift @@ -15,7 +15,8 @@ nonisolated enum SSHTunnelError: Error, LocalizedError, Equatable, Sendable { case jumpHostFailed(host: String, reason: String) case jumpHostKeysUnreadable(String) case tunnelClosed - case negotiationFailed(SSHNegotiationFailure) + // jumpHost is nil when the SSH server itself failed. + case negotiationFailed(SSHNegotiationFailure, jumpHost: String?) case securityKeyUnsupported case passphraseRequired case passphraseIncorrect @@ -46,8 +47,9 @@ nonisolated enum SSHTunnelError: Error, LocalizedError, Equatable, Sendable { case .jumpHostKeysUnreadable(let reason): return String(format: String(localized: "The jump host keys on this device could not be read: %@"), reason) case .tunnelClosed: return "SSH tunnel is closed" - case .negotiationFailed(let failure): - return Self.describe(failure) + case .negotiationFailed(let failure, let jumpHost): + guard let jumpHost else { return failure.explanation } + return String(format: String(localized: "Jump host %1$@ failed: %2$@"), jumpHost, failure.explanation) case .securityKeyUnsupported: return String( localized: "This SSH private key belongs to a security key (FIDO), which TablePro on iPhone and iPad cannot sign with." @@ -72,46 +74,4 @@ nonisolated enum SSHTunnelError: Error, LocalizedError, Equatable, Sendable { return String(localized: "The SSH server did not accept the verification code.") } } - - private static func describe(_ failure: SSHNegotiationFailure) -> String { - var sentences: [String] = [] - - switch failure.stage { - case .keyExchangeOrHostKey: - sentences.append(String(localized: "TablePro and the SSH server have no key exchange or host key method in common.")) - case .cipher: - sentences.append(String(localized: "TablePro and the SSH server have no cipher in common.")) - case .mac: - sentences.append(String(localized: "TablePro and the SSH server have no MAC algorithm in common.")) - case .undetermined: - sentences.append(String(localized: "TablePro and the SSH server could not agree on how to encrypt the connection.")) - } - - if let software = failure.serverSoftware { - sentences.append(String(format: String(localized: "The server runs %@."), software)) - } - - if !failure.supported.isEmpty { - let list = failure.supported.formatted(.list(type: .and)) - switch failure.stage { - case .keyExchangeOrHostKey: - sentences.append(String(format: String(localized: "TablePro supports these key exchange methods: %@."), list)) - case .cipher: - sentences.append(String(format: String(localized: "TablePro supports these ciphers: %@."), list)) - case .mac: - sentences.append(String(format: String(localized: "TablePro supports these MAC algorithms: %@."), list)) - case .undetermined: - break - } - } - - if !failure.supportedHostKeys.isEmpty { - sentences.append(String( - format: String(localized: "It supports these host key types: %@."), - failure.supportedHostKeys.formatted(.list(type: .and)) - )) - } - - return sentences.joined(separator: " ") - } } diff --git a/apple/TableProMobile/TableProMobile/SSH/SSHTunnelFactory.swift b/apple/TableProMobile/TableProMobile/SSH/SSHTunnelFactory.swift index 072596286e..13a3eeb73b 100644 --- a/apple/TableProMobile/TableProMobile/SSH/SSHTunnelFactory.swift +++ b/apple/TableProMobile/TableProMobile/SSH/SSHTunnelFactory.swift @@ -185,8 +185,9 @@ nonisolated enum SSHTunnelFactory { } } - // A hop's failure names the hop. A host key question already names its host and port, and - // anything that is not an SSH error, such as a cancellation, passes through as it is. + // A hop's failure names the hop. A failed negotiation keeps its own case, whose advice holds on + // any hop. A host key question already names its host and port, and anything that is not an + // SSH error, such as a cancellation, passes through as it is. private static func failing(as target: Target, _ step: () async throws -> Void) async throws { guard case .jumpHost = target.role else { try await step() @@ -196,12 +197,14 @@ nonisolated enum SSHTunnelFactory { try await step() } catch let error as SSHTunnelError { switch error { + case .negotiationFailed(let failure, nil): + throw SSHTunnelError.negotiationFailed(failure, jumpHost: target.host) case .connectionFailed, .handshakeFailed, .authenticationFailed, .noAvailablePort, - .channelOpenFailed, .tunnelClosed, .negotiationFailed, + .channelOpenFailed, .tunnelClosed, .securityKeyUnsupported, .passphraseRequired, .passphraseIncorrect, .additionalFactorRequired, .promptUnanswerable, .verificationCodeRejected: throw SSHTunnelError.jumpHostFailed(host: target.host, reason: error.localizedDescription) - case .hostKeyRejected, .hostKeyUnverified, .usernameMissing, .jumpHostNeedsKey, + case .negotiationFailed, .hostKeyRejected, .hostKeyUnverified, .usernameMissing, .jumpHostNeedsKey, .jumpHostSharedKeyMissing, .jumpHostFailed, .jumpHostKeysUnreadable: throw error } diff --git a/apple/TableProMobile/TableProMobile/SSH/SSHTunnelStages.swift b/apple/TableProMobile/TableProMobile/SSH/SSHTunnelStages.swift index 6bc12f3b89..5aeee771bd 100644 --- a/apple/TableProMobile/TableProMobile/SSH/SSHTunnelStages.swift +++ b/apple/TableProMobile/TableProMobile/SSH/SSHTunnelStages.swift @@ -360,11 +360,18 @@ nonisolated final class LibSSH2TunnelStages: SSHTunnelStages, @unchecked Sendabl libssh2_session_set_blocking(session, 1) libssh2_session_set_timeout(session, blockingCallTimeoutMilliseconds) - let rc = libssh2_session_handshake(session, socketFD) - guard rc == 0 else { - let failure = LibSSH2HandshakeFailure.error(session: session, rc: rc) + if let failure = LibSSH2Handshake.perform(session, socket: socketFD) { libssh2_session_free(session) - throw failure + switch failure { + case .negotiation(let negotiation): + throw SSHTunnelError.negotiationFailed(negotiation, jumpHost: nil) + case .timedOut: + // libssh2 says "Timed out waiting on socket", which reads as blocked Local Network access, + // but the TCP connect already succeeded. + throw SSHTunnelError.handshakeFailed(String(localized: "The SSH server did not answer in time.")) + case .failed(let detail): + throw SSHTunnelError.handshakeFailed(detail) + } } return session diff --git a/apple/TableProMobile/TableProMobileTests/SSH/SSHTunnelFactoryTests.swift b/apple/TableProMobile/TableProMobileTests/SSH/SSHTunnelFactoryTests.swift index 6cbb274efd..af50bb4d73 100644 --- a/apple/TableProMobile/TableProMobileTests/SSH/SSHTunnelFactoryTests.swift +++ b/apple/TableProMobile/TableProMobileTests/SSH/SSHTunnelFactoryTests.swift @@ -383,23 +383,14 @@ struct SSHTunnelFactoryTests { @Test( "A named failure at a hop names the hop and keeps its words", - arguments: zip( - [SpyTunnelStages.Step.handshake, .authenticatePrivateKey, .authenticatePrivateKey, .authenticatePrivateKey], - [ - SSHTunnelError.negotiationFailed(SSHNegotiationFailure( - stage: .keyExchangeOrHostKey, - serverSoftware: "OpenSSH_10.3", - supported: ["curve25519-sha256"], - supportedHostKeys: ["ssh-ed25519"] - )), - .passphraseIncorrect, - .securityKeyUnsupported, - .additionalFactorRequired(["keyboard-interactive"]), - ] - ) + arguments: [ + SSHTunnelError.passphraseIncorrect, + .securityKeyUnsupported, + .additionalFactorRequired(["keyboard-interactive"]), + ] ) - func namedHopFailureNamesTheHop(step: SpyTunnelStages.Step, failure: SSHTunnelError) async { - let stages = SpyTunnelStages(failingAt: step, with: failure) + func namedHopFailureNamesTheHop(failure: SSHTunnelError) async { + let stages = SpyTunnelStages(failingAt: .authenticatePrivateKey, with: failure) await #expect(throws: SSHTunnelError.jumpHostFailed(host: Self.bastion.host, reason: failure.localizedDescription)) { try await Self.create( @@ -413,6 +404,28 @@ struct SSHTunnelFactoryTests { #expect(stages.discardCount == 1) } + @Test("A hop that fails negotiation keeps the negotiation case and names the hop") + func hopNegotiationFailureKeepsItsCase() async { + let negotiation = SSHNegotiationFailure( + stage: .keyExchangeOrHostKey, + serverSoftware: "OpenSSH_10.3", + supported: ["curve25519-sha256"], + supportedHostKeys: ["ssh-ed25519"] + ) + let stages = SpyTunnelStages(failingAt: .handshake, with: SSHTunnelError.negotiationFailed(negotiation, jumpHost: nil)) + + await #expect(throws: SSHTunnelError.negotiationFailed(negotiation, jumpHost: Self.bastion.host)) { + try await Self.create( + stages: stages, + authMethod: .password, + jumpHosts: [Self.bastion], + jumpHostCredentials: [Self.bastion.id: .privateKey(key: "HOP KEY", passphrase: nil)] + ) + } + + #expect(stages.discardCount == 1) + } + @Test("A channel the hop could not open names that hop") func jumpFailureNamesTheHopThatOpenedIt() async { let refusal = SSHTunnelError.channelOpenFailed("127.0.0.1:22: Channel open failure (administratively prohibited)") diff --git a/apple/TableProMobile/TableProMobileTests/SSHTunnelErrorClassifierTests.swift b/apple/TableProMobile/TableProMobileTests/SSHTunnelErrorClassifierTests.swift index 2fbf5fcb9c..22c6f6e772 100644 --- a/apple/TableProMobile/TableProMobileTests/SSHTunnelErrorClassifierTests.swift +++ b/apple/TableProMobile/TableProMobileTests/SSHTunnelErrorClassifierTests.swift @@ -28,7 +28,7 @@ struct SSHTunnelErrorClassifierTests { supportedHostKeys: ["ssh-ed25519", "rsa-sha2-512"] ) - let error = ErrorClassifier.classify(SSHTunnelError.negotiationFailed(failure), context: Self.context) + let error = ErrorClassifier.classify(SSHTunnelError.negotiationFailed(failure, jumpHost: nil), context: Self.context) #expect(error.category == .ssh) #expect(error.recovery == String(localized: "Ask the SSH server's administrator to turn on a method TablePro supports.")) @@ -38,6 +38,20 @@ struct SSHTunnelErrorClassifierTests { #expect(error.message.contains("rsa-sha2-512")) } + @Test("A jump host that fails negotiation gets the negotiation recovery and is named") + func jumpHostNegotiationFailureIsNotAnAddressProblem() { + let failure = SSHNegotiationFailure(stage: .mac, serverSoftware: nil, supported: ["hmac-sha2-256"], supportedHostKeys: []) + + let error = ErrorClassifier.classify( + SSHTunnelError.negotiationFailed(failure, jumpHost: "bastion.internal"), + context: Self.context + ) + + #expect(error.recovery == SSHNegotiationFailure.recoverySuggestion) + #expect(error.message.contains("bastion.internal")) + #expect(error.message.contains(failure.explanation)) + } + @Test("An SSH connect that timed out keeps the Local Network recovery") func connectTimeoutKeepsTheLocalNetworkRecovery() { let error = ErrorClassifier.classify(SSHTunnelError.connectionFailed("Connection timed out"), context: Self.context) diff --git a/apple/TableProMobile/project.yml b/apple/TableProMobile/project.yml index 99a3f90c7a..515c2ad5c5 100644 --- a/apple/TableProMobile/project.yml +++ b/apple/TableProMobile/project.yml @@ -101,6 +101,7 @@ targets: - ../TablePro/Core/SSH/LibSSH2ChannelIO.swift - ../TablePro/Core/SSH/LibSSH2ForwardChannel.swift - ../TablePro/Core/SSH/LibSSH2JumpRelay.swift + - ../TablePro/Core/SSH/LibSSH2Handshake.swift - ../TablePro/Core/SSH/SSHKeepAliveResult.swift # Shortcuts payload parsing and row planning, shared with the macOS app's App Intents. - ../TablePro/Core/Shortcuts/Shared diff --git a/apple/TableProTests/Core/SSH/LibSSH2NegotiationTests.swift b/apple/TableProTests/Core/SSH/LibSSH2NegotiationTests.swift new file mode 100644 index 0000000000..693051055f --- /dev/null +++ b/apple/TableProTests/Core/SSH/LibSSH2NegotiationTests.swift @@ -0,0 +1,165 @@ +import Foundation +import Testing + +import CLibSSH2 +import TableProSSHTransport + +@testable import TablePro + +// The server side is written ahead into a socket pair: a banner, then a KEXINIT, a DISCONNECT or +// nothing. The method names it offers are made up, so no libssh2 build can agree on them. +struct LibSSH2NegotiationTests { + private static let endpoint = ConnectionTimeoutEndpoint.tunnel("ssh.example:22") + private static let banner = Data("SSH-2.0-OpenSSH_10.3\r\n".utf8) + + @Test("No common key exchange names that stage, the server software and TablePro's methods") + func keyExchangeFailure() throws { + let failure = try negotiationFailure(offering: ServerKexInit(kex: "unknown-kex@example.com")) + + #expect(failure.stage == .keyExchangeOrHostKey) + #expect(failure.serverSoftware == "OpenSSH_10.3") + #expect(failure.supported.contains("curve25519-sha256")) + #expect(!failure.supported.contains("ext-info-c")) + #expect(failure.supportedHostKeys.contains("ssh-ed25519")) + } + + @Test("An agreed key exchange with no common cipher names the cipher stage") + func cipherFailure() throws { + let failure = try negotiationFailure(offering: ServerKexInit(cipher: "unknown-cipher@example.com")) + + #expect(failure.stage == .cipher) + #expect(failure.supported.contains("aes256-ctr")) + #expect(failure.supportedHostKeys.isEmpty) + } + + @Test("An agreed cipher that needs a MAC with no common MAC names the MAC stage") + func macFailure() throws { + let failure = try negotiationFailure(offering: ServerKexInit(mac: "unknown-mac@example.com")) + + #expect(failure.stage == .mac) + #expect(failure.supported.contains("hmac-sha2-256")) + } + + @Test("A server that hangs up after its banner is not a negotiation failure") + func hangUpAfterBannerIsNotANegotiation() throws { + let error = try handshakeError(serverWrites: Self.banner, thenHangsUp: true) + + #expect(error == .tunnelCreationFailed( + "SSH handshake failed: The SSH server closed the connection during the handshake." + )) + } + + @Test("A server that disconnects after its banner is named with its own reason, not as a negotiation failure") + func disconnectAfterBannerIsNotANegotiation() throws { + var written = Self.banner + written.append(SSHPacket.disconnect(reason: 12, description: "Too many connections\r\n")) + + let error = try handshakeError(serverWrites: written, thenHangsUp: false) + + #expect(error == .tunnelCreationFailed( + "SSH handshake failed: The SSH server ended the connection during the handshake: Too many connections" + )) + } + + @Test("A handshake that fails before negotiation keeps libssh2's text") + func bannerFailureIsNotANegotiation() throws { + let error = try handshakeError(serverWrites: Data("HTTP/1.1 400 Bad Request\r\n".utf8), thenHangsUp: true) + + guard case .tunnelCreationFailed(let message) = error else { + Issue.record("expected tunnelCreationFailed, got \(error)") + return + } + #expect(message.hasPrefix("SSH handshake failed: ")) + #expect(!message.contains("closed the connection during the handshake")) + } + + private func negotiationFailure(offering kexInit: ServerKexInit) throws -> SSHNegotiationFailure { + var written = Self.banner + written.append(kexInit.packet) + let error = try handshakeError(serverWrites: written, thenHangsUp: false) + guard case .negotiationFailed(let failure, let jumpHost) = error else { + Issue.record("expected negotiationFailed, got \(error)") + throw error + } + #expect(jumpHost == nil) + return failure + } + + // What the server says fits the socket buffer, so it is written before the client starts. A + // hang-up closes only the server's sending side, so the client's own writes still land. + private func handshakeError(serverWrites data: Data, thenHangsUp: Bool) throws -> SSHTunnelError { + _ = LibSSH2TunnelFactory.initialized + let pair = SocketPair() + defer { pair.close() } + try #require(pair.a >= 0) + + let sent = data.withUnsafeBytes { Darwin.write(pair.b, $0.baseAddress, $0.count) } + try #require(sent == data.count) + if thenHangsUp { + shutdown(pair.b, SHUT_WR) + } + + let deadline = ConnectionDeadline(configuredSeconds: 10) + let attempt = SSHConnectionAttempt(deadline: deadline, endpoint: Self.endpoint) + return try #require(throws: SSHTunnelError.self) { + let session = try LibSSH2TunnelFactory.createSession( + socketFD: pair.a, + endpoint: Self.endpoint, + deadline: deadline, + attempt: attempt + ) + libssh2_session_free(session) + } + } +} + +// RFC 4253, 6: sent before any keys exist, so no MAC, and padding to a multiple of 8. +private enum SSHPacket { + static func frame(_ payload: Data) -> Data { + var padding = 8 - (5 + payload.count) % 8 + if padding < 4 { padding += 8 } + var packet = uint32(UInt32(1 + payload.count + padding)) + packet.append(UInt8(padding)) + packet.append(payload) + packet.append(Data(repeating: 0, count: padding)) + return packet + } + + // RFC 4253, 11.1. + static func disconnect(reason: UInt32, description: String) -> Data { + var payload = Data([1]) + payload.append(uint32(reason)) + payload.append(string(description)) + payload.append(string("")) + return frame(payload) + } + + static func string(_ value: String) -> Data { + var data = uint32(UInt32(value.utf8.count)) + data.append(Data(value.utf8)) + return data + } + + static func uint32(_ value: UInt32) -> Data { + withUnsafeBytes(of: value.bigEndian) { Data($0) } + } +} + +// RFC 4253, 7.1. +private struct ServerKexInit { + var kex = "curve25519-sha256" + var hostKey = "ssh-ed25519" + var cipher = "aes128-ctr" + var mac = "hmac-sha2-256" + + var packet: Data { + var payload = Data([20]) + payload.append(Data(repeating: 0x5A, count: 16)) + for list in [kex, hostKey, cipher, cipher, mac, mac, "none", "none", "", ""] { + payload.append(SSHPacket.string(list)) + } + payload.append(0) + payload.append(contentsOf: [0, 0, 0, 0]) + return SSHPacket.frame(payload) + } +} diff --git a/apple/TableProTests/Core/SSH/SSHTunnelErrorTests.swift b/apple/TableProTests/Core/SSH/SSHTunnelErrorTests.swift index e5366d8c36..1b655b854a 100644 --- a/apple/TableProTests/Core/SSH/SSHTunnelErrorTests.swift +++ b/apple/TableProTests/Core/SSH/SSHTunnelErrorTests.swift @@ -1,14 +1,8 @@ -// -// SSHTunnelErrorTests.swift -// TableProTests -// -// Tests for SSHTunnelError descriptions and isLocalPortBindFailure classification. -// - import Foundation import os @testable import TablePro import TableProPluginKit +import TableProSSHTransport import TableProTestSupport import Testing @@ -106,6 +100,67 @@ struct SSHTunnelErrorTests { #expect(error.errorDescription?.contains("6") == true) } + @Test("A failed negotiation reads as the shared explanation, with the jump host named when one failed") + func negotiationFailedDescription() { + let failure = SSHNegotiationFailure( + stage: .mac, + serverSoftware: "OpenSSH_10.3", + supported: ["hmac-sha2-256", "hmac-sha2-512"], + supportedHostKeys: [] + ) + + let server = SSHTunnelError.negotiationFailed(failure, jumpHost: nil).errorDescription ?? "" + let hop = SSHTunnelError.negotiationFailed(failure, jumpHost: "bastion.example").errorDescription ?? "" + + #expect(server == failure.explanation) + #expect(server.contains("OpenSSH_10.3")) + #expect(server.contains("hmac-sha2-512")) + #expect(hop.contains("bastion.example")) + #expect(hop.contains(failure.explanation)) + } + + @Test("A failed negotiation suggests asking for a supported method, and happened after dialing") + func negotiationFailedRecovery() { + let failure = SSHNegotiationFailure(stage: .cipher, serverSoftware: nil, supported: [], supportedHostKeys: []) + let error = SSHTunnelError.negotiationFailed(failure, jumpHost: nil) + + #expect(error.recoverySuggestion == SSHNegotiationFailure.recoverySuggestion) + #expect((error as NSError).localizedRecoverySuggestion == SSHNegotiationFailure.recoverySuggestion) + #expect(SSHTunnelError.noAvailablePort.recoverySuggestion == nil) + #expect(!error.failedBeforeDialing) + } + + @Test("A step on a jump host names the hop and keeps the step's words") + func attributedToJumpHost() { + let failure = SSHNegotiationFailure(stage: .keyExchangeOrHostKey, serverSoftware: nil, supported: [], supportedHostKeys: []) + let rejected = SSHTunnelError.authenticationFailed(reason: .privateKey) + + #expect(SSHTunnelError.negotiationFailed(failure, jumpHost: nil).attributed(to: "bastion") + == .negotiationFailed(failure, jumpHost: "bastion")) + #expect(rejected.attributed(to: "bastion") + == .jumpHostFailed(host: "bastion", reason: rejected.errorDescription ?? "")) + #expect(SSHTunnelError.tunnelCreationFailed("SSH handshake failed: Failed getting banner").attributed(to: "bastion") + == .jumpHostFailed(host: "bastion", reason: "SSH handshake failed: Failed getting banner")) + #expect(SSHTunnelError.jumpHostFailed(host: "bastion", reason: "refused").errorDescription? + .contains("bastion") == true) + } + + @Test( + "A cancelled sign-in, a host key answer and an error that names its host stay as they are", + arguments: [ + SSHTunnelError.authenticationFailed(reason: .cancelled), + .hostKeyVerificationFailed, + .usernameMissing(host: "bastion"), + .negotiationFailed( + SSHNegotiationFailure(stage: .mac, serverSoftware: nil, supported: [], supportedHostKeys: []), + jumpHost: "first-hop" + ), + ] + ) + func attributionKeepsItsOwnErrors(error: SSHTunnelError) { + #expect(error.attributed(to: "second-hop") == error) + } + @Test("A missing SSH username is plain text that names the host") func usernameMissingDescription() { let description = SSHTunnelError.usernameMissing(host: "bastion.example").errorDescription ?? "" diff --git a/apple/changelog.d/3779.changed.1.md b/apple/changelog.d/3779.changed.1.md new file mode 100644 index 0000000000..95ddc81f96 --- /dev/null +++ b/apple/changelog.d/3779.changed.1.md @@ -0,0 +1 @@ +An SSH error on a jump host names that jump host. diff --git a/apple/changelog.d/3779.changed.md b/apple/changelog.d/3779.changed.md new file mode 100644 index 0000000000..10698c5d43 --- /dev/null +++ b/apple/changelog.d/3779.changed.md @@ -0,0 +1 @@ +An SSH server that shares no key exchange, cipher or MAC with TablePro gets an error naming which one. diff --git a/apple/changelog.d/3779.fixed.md b/apple/changelog.d/3779.fixed.md new file mode 100644 index 0000000000..570b11b11c --- /dev/null +++ b/apple/changelog.d/3779.fixed.md @@ -0,0 +1 @@ +A missing SSH key file asked for a passphrase instead of saying the file was not there. diff --git a/apple/scripts/probes/check-ssh-tunnel-matrix.sh b/apple/scripts/probes/check-ssh-tunnel-matrix.sh index 5bc720fd92..eb6fbbd8a3 100755 --- a/apple/scripts/probes/check-ssh-tunnel-matrix.sh +++ b/apple/scripts/probes/check-ssh-tunnel-matrix.sh @@ -2,16 +2,17 @@ # # Opens the iOS and the macOS app's SSH tunnel against real OpenSSH servers and sends a Redis PING # through it, once per cell: server version, key type and format, key source, auth method, agent, -# verification code, key exchange, many clients and many tunnels, and a server that dies -# mid-session. A cell passes when it connects or ends with the error it names in that app's own -# words; anything else fails the run. Cell names start with the app, ios/ or macos/. +# verification code, key exchange, cipher and MAC negotiation, many clients and many tunnels, and a +# server that dies mid-session. A cell passes when it connects or ends with the error it names in +# that app's own words; anything else fails the run. Cell names start with the app, ios/ or macos/. # # Each harness is its app's own tunnel code, compiled with the libssh2 that app ships: # ssh-tunnel-matrix/build-ios-harness.sh for the simulator, where every cell runs through # `xcrun simctl spawn -s` with no booted device, and build-mac-harness.sh for this Mac, where cells # run with an empty home (CFFIXED_USER_HOME, so no ~/.ssh/config) and an ssh-agent started here. -# The post-quantum cells expect a connection when that harness's libssh2 has -# mlkem768x25519-sha256, and the app's key exchange error when it does not. The servers come from +# A server limited to a method the harness's libssh2 lacks expects that app's named negotiation +# failure, and a connection once a later libssh2 offers the method: mlkem768x25519-sha256 on pq10 +# and pq9, sntrup761x25519-sha512 on kex10, 3des-cbc on cipher10. The servers come from # scripts/ssh-test-servers.sh. It is a probe, not a test, because no CI job has Docker. # # Usage: @@ -225,18 +226,22 @@ loss_cell() { report "$name" "$expected" "$result" "$verdict" } -# Each app names a failure in its own words. The Mac reports every failed handshake as one -# tunnelCreationFailed, and a key it cannot use ends at the passphrase prompt, which the macOS -# harness dismisses, as the user would to give up. +# Both apps word a negotiation failure the same way (TableProSSHTransport). +KEX_TEXT="TablePro and the SSH server have no key exchange or host key method in common." +CIPHER_TEXT="TablePro and the SSH server have no cipher in common." +MAC_TEXT="TablePro and the SSH server have no MAC algorithm in common." + +# Each app names any other failure in its own words. A key the Mac cannot use ends at the +# passphrase prompt, which the macOS harness dismisses, as the user would to give up. expectations() { case "$APP" in ios) HARNESS="$IOS_HARNESS" RUN=(xcrun simctl spawn -s "$UDID" "$HARNESS") KEY_SOURCES="file memory" - KEX="ERROR negotiationFailed(*Stage.keyExchangeOrHostKey*" - MAC="ERROR negotiationFailed(*Stage.mac,*" - HOP_KEX="ERROR jumpHostFailed(*no key exchange or host key method in common*" + KEX="ERROR negotiationFailed(*Stage.keyExchangeOrHostKey,*) | $KEX_TEXT*" + CIPHER="ERROR negotiationFailed(*Stage.cipher,*) | $CIPHER_TEXT*" + MAC="ERROR negotiationFailed(*Stage.mac,*) | $MAC_TEXT*" SECURITY_KEY="ERROR securityKeyUnsupported |*" NO_PASSPHRASE="ERROR passphraseRequired |*" WRONG_PASSPHRASE="ERROR passphraseIncorrect |*" @@ -247,9 +252,9 @@ expectations() { HARNESS="$MAC_HARNESS" RUN=(env "CFFIXED_USER_HOME=$WORK/home" "SSH_AUTH_SOCK=$WORK/agent.sock" "$HARNESS") KEY_SOURCES="file" - KEX="ERROR tunnelCreationFailed(\"SSH handshake failed: *" - MAC="$KEX" - HOP_KEX="$KEX" + KEX="ERROR negotiationFailed(*Stage.keyExchangeOrHostKey,*jumpHost: nil) | $KEX_TEXT*" + CIPHER="ERROR negotiationFailed(*Stage.cipher,*jumpHost: nil) | $CIPHER_TEXT*" + MAC="ERROR negotiationFailed(*Stage.mac,*jumpHost: nil) | $MAC_TEXT*" SECURITY_KEY="ERROR authenticationFailed(reason: *securityKeyFile) |*" NO_PASSPHRASE="ERROR authenticationFailed(reason: *privateKey) |*" WRONG_PASSPHRASE="$NO_PASSPHRASE" @@ -257,18 +262,27 @@ expectations() { NO_CODE="ERROR authenticationFailed(reason: *keyboardInteractive) |*" ;; esac - PQ="$KEX" - PQ_HOP="$HOP_KEX" - if grep -a -q 'mlkem768x25519-sha256' "$HARNESS"; then - PQ="CONNECTED" - PQ_HOP="CONNECTED" +} + +# A jump host that fails key exchange is named, whichever hop of the chain it is. +hop_kex() { + echo "ERROR negotiationFailed(*Stage.keyExchangeOrHostKey,*jumpHost: Optional(\"$1\")) | Jump host $1 failed: $KEX_TEXT*" +} + +# The expectation for a server limited to $1: a connection once the harness's libssh2 offers it. +lacking() { + if grep -a -q -- "$1" "$HARNESS"; then + echo "CONNECTED" + else + echo "$2" fi } PASSWORD=(--auth password --password "$SSH_TEST_PASSWORD") TOTP=(--totp-secret "$SSH_TEST_TOTP_SECRET") CONNECTED="CONNECTED" -# hop2 is reachable only from the other servers, under its network alias. +# hop2 is reachable only from the other servers, under its network alias. Every server listens on +# hop2's port inside that network. HOP2=(--host "${SSH_TEST_HOP2%:*}") HOP2_PORT="${SSH_TEST_HOP2##*:}" HOP1=(--jump "$SSH_TEST_HOST:$SSH_TEST_PORT_HOP1") @@ -303,8 +317,10 @@ run_cells() { on macos && cell kbdpass/keyboard-interactive "$CONNECTED" "$SSH_TEST_PORT_KBDPASS" \ --auth kbdint --password "$SSH_TEST_PASSWORD" - cell pq10/password "$PQ" "$SSH_TEST_PORT_PQ10" "${PASSWORD[@]}" - cell pq9/password "$PQ" "$SSH_TEST_PORT_PQ9" "${PASSWORD[@]}" + cell pq10/password "$(lacking mlkem768x25519-sha256 "$KEX")" "$SSH_TEST_PORT_PQ10" "${PASSWORD[@]}" + cell pq9/password "$(lacking mlkem768x25519-sha256 "$KEX")" "$SSH_TEST_PORT_PQ9" "${PASSWORD[@]}" + cell kex10/password "$(lacking sntrup761x25519-sha512 "$KEX")" "$SSH_TEST_PORT_KEX10" "${PASSWORD[@]}" + cell cipher10/password "$(lacking 3des-cbc "$CIPHER")" "$SSH_TEST_PORT_CIPHER10" "${PASSWORD[@]}" cell mac10/password "$MAC" "$SSH_TEST_PORT_MAC10" "${PASSWORD[@]}" for source in $KEY_SOURCES; do @@ -347,6 +363,10 @@ run_cells() { --auth kbdint --kbdint-answer "$(totp_code)" cell totp/keyboard-interactive/wrong-code "$NO_CODE" "$SSH_TEST_PORT_TOTP" \ --auth kbdint --kbdint-answer 000000 + # A key file that is not there asks for no passphrase; the prompt's stand-in would end it + # as a rejected key. + cell v10/missing-key/file "ERROR tunnelCreationFailed(\"Private key file not found at: *" "$SSH_TEST_PORT_V10" \ + --auth key --key-file "$SSH_TEST_KEYS/no-such-key" fi cell two-hops/password "$CONNECTED" "$HOP2_PORT" "${HOP2[@]}" "${HOP1[@]}" --jump-key "$SSH_TEST_KEYS/ed25519" \ @@ -360,11 +380,19 @@ run_cells() { else cell two-hops/hop-agent "$CONNECTED" "$HOP2_PORT" "${HOP2[@]}" "${HOP1[@]}" --jump-key agent "${PASSWORD[@]}" # A Mac jump host has no passphrase field, so its key's passphrase comes from the prompt. - cell two-hops/hop-passphrase-key "$NO_PASSPHRASE" "$HOP2_PORT" "${HOP2[@]}" "${HOP1[@]}" \ - --jump-key "$SSH_TEST_KEYS/ed25519-pass" "${PASSWORD[@]}" + cell two-hops/hop-passphrase-key \ + "ERROR jumpHostFailed(host: \"$SSH_TEST_HOST\", reason: *) | Jump host $SSH_TEST_HOST failed: SSH private key rejected*" \ + "$HOP2_PORT" "${HOP2[@]}" "${HOP1[@]}" --jump-key "$SSH_TEST_KEYS/ed25519-pass" "${PASSWORD[@]}" fi - cell two-hops/pq-hop "$PQ_HOP" "$HOP2_PORT" "${HOP2[@]}" --jump "$SSH_TEST_HOST:$SSH_TEST_PORT_PQ10" \ + cell two-hops/pq-hop "$(lacking mlkem768x25519-sha256 "$(hop_kex "$SSH_TEST_HOST")")" "$HOP2_PORT" "${HOP2[@]}" \ + --jump "$SSH_TEST_HOST:$SSH_TEST_PORT_PQ10" --jump-key "$SSH_TEST_KEYS/ed25519" "${PASSWORD[@]}" + # Key exchange fails on the first hop, on the server behind a hop, and on the second of two hops. + cell two-hops/kex-hop "$(lacking sntrup761x25519-sha512 "$(hop_kex "$SSH_TEST_HOST")")" "$HOP2_PORT" "${HOP2[@]}" \ + --jump "$SSH_TEST_HOST:$SSH_TEST_PORT_KEX10" --jump-key "$SSH_TEST_KEYS/ed25519" "${PASSWORD[@]}" + cell two-hops/kex-server "$(lacking sntrup761x25519-sha512 "$KEX")" "$HOP2_PORT" --host kex10 "${HOP1[@]}" \ --jump-key "$SSH_TEST_KEYS/ed25519" "${PASSWORD[@]}" + cell three-hops/kex-second-hop "$(lacking sntrup761x25519-sha512 "$(hop_kex kex10)")" "$HOP2_PORT" "${HOP2[@]}" \ + "${HOP1[@]}" --jump "kex10:$HOP2_PORT" --jump-key "$SSH_TEST_KEYS/ed25519" "${PASSWORD[@]}" cell two-hops/30-clients-one-tunnel "CONNECTED clients=30" "$HOP2_PORT" "${HOP2[@]}" "${HOP1[@]}" \ --jump-key "$SSH_TEST_KEYS/ed25519" --mode clients --count 30 "${PASSWORD[@]}" diff --git a/apple/scripts/probes/ssh-tunnel-matrix/mac-harness.swift b/apple/scripts/probes/ssh-tunnel-matrix/mac-harness.swift index 77dbd95878..dae0360e53 100644 --- a/apple/scripts/probes/ssh-tunnel-matrix/mac-harness.swift +++ b/apple/scripts/probes/ssh-tunnel-matrix/mac-harness.swift @@ -3,10 +3,10 @@ import TableProSSHConfig import TableProSSHTransport // Compiled into one module with the macOS app's tunnel sources (build-mac-harness.sh) to reach the -// internal LibSSH2TunnelFactory. The app files it leaves out open UI, so the stand-ins below take -// their place: every host key is trusted, a passphrase prompt is dismissed, and a -// keyboard-interactive prompt gets --kbdint-answer or an empty answer. The modes live in -// harness-common.swift. +// internal LibSSH2TunnelFactory. The app files it leaves out open UI or reach past the tunnel, so +// the stand-ins below take their place: every host key is trusted, a passphrase prompt is +// dismissed, and a keyboard-interactive prompt gets --kbdint-answer or an empty answer. The modes +// live in harness-common.swift. nonisolated enum MacHarness { static let options = TunnelHarness.options() @@ -22,6 +22,10 @@ nonisolated struct AppStorageEnvironment: Sendable { let isIsolated: Bool } +nonisolated protocol ConnectionPreflightReporting { + var failedBeforeDialing: Bool { get } +} + nonisolated enum HostKeyVerifier { static func verify( keyData: Data, diff --git a/apple/scripts/ssh-test-servers.sh b/apple/scripts/ssh-test-servers.sh index e206e63342..434e2ecc93 100755 --- a/apple/scripts/ssh-test-servers.sh +++ b/apple/scripts/ssh-test-servers.sh @@ -20,8 +20,8 @@ # ssh-test-servers.sh start # start a killed server again and wait for its banner # ssh-test-servers.sh env # NAME=value lines for a runner to eval # -# Servers: v10 v9 pq10 pq9 mac10 rsa256 rsa512 kbdpass passtotp passtotp1 keytotp totp maxauth hop1 -# hop2 loss. +# Servers: v10 v9 pq10 pq9 kex10 cipher10 mac10 rsa256 rsa512 kbdpass passtotp passtotp1 keytotp totp +# maxauth hop1 hop2 loss. # A bare up removes what an earlier up left and makes new keys; up with names restarts only those. # Ports run from TABLEPRO_SSH_TEST_PORT_BASE + 1 (default 22601). Keys live in TABLEPRO_SSH_TEST_DIR # (default $TMPDIR/tablepro-ssh-test-servers). decoy-1 to decoy-4 are keys no server accepts, for an @@ -45,7 +45,7 @@ PASSWORD="tppass" KEY_PASSPHRASE="tp-key-pass" # The usual documentation example: it signs in to these throwaway servers and nowhere else. TOTP_SECRET="JBSWY3DPEHPK3PXP" -SERVERS="v10 v9 pq10 pq9 mac10 rsa256 rsa512 kbdpass passtotp passtotp1 keytotp totp maxauth hop1 hop2 loss" +SERVERS="v10 v9 pq10 pq9 kex10 cipher10 mac10 rsa256 rsa512 kbdpass passtotp passtotp1 keytotp totp maxauth hop1 hop2 loss" AUTHORIZED_KEYS="ed25519 ed25519-pass rsa rsa-pass ecdsa ecdsa-pass rsa-pem rsa-pem-pass ecdsa-pem ecdsa-pem-pass sk-ed25519" DECOY_KEYS="decoy-1 decoy-2 decoy-3 decoy-4" @@ -68,6 +68,8 @@ server_spec() { pq10) echo "10 3 pq-only true" ;; pq9) echo "9 4 pq-only true" ;; mac10) echo "10 5 mac-only true" ;; + kex10) echo "10 16 kex-only true" ;; + cipher10) echo "10 17 cipher-only true" ;; rsa256) echo "10 6 rsa-sha2-256 true" ;; rsa512) echo "10 7 rsa-sha2-512 true" ;; kbdpass) echo "10 8 kbdint-password true" ;; diff --git a/apple/scripts/ssh-test-servers/cipher-only.conf b/apple/scripts/ssh-test-servers/cipher-only.conf new file mode 100644 index 0000000000..356be00e36 --- /dev/null +++ b/apple/scripts/ssh-test-servers/cipher-only.conf @@ -0,0 +1,2 @@ +# Key exchange agrees and the cipher does not: the bundled libssh2 has no 3des-cbc. +Ciphers 3des-cbc diff --git a/apple/scripts/ssh-test-servers/kex-only.conf b/apple/scripts/ssh-test-servers/kex-only.conf new file mode 100644 index 0000000000..86fa8e9a97 --- /dev/null +++ b/apple/scripts/ssh-test-servers/kex-only.conf @@ -0,0 +1,3 @@ +# A key exchange the bundled libssh2 does not offer, so both apps must name the key exchange stage. +# If a later libssh2 adds it, the matrix expects a connection here instead. +KexAlgorithms sntrup761x25519-sha512