diff --git a/apple/Packages/TableProCore/Package.swift b/apple/Packages/TableProCore/Package.swift index 668794c9fb..d237526e2f 100644 --- a/apple/Packages/TableProCore/Package.swift +++ b/apple/Packages/TableProCore/Package.swift @@ -276,7 +276,7 @@ let package = Package( ), .testTarget( name: "TableProSSHTransportTests", - dependencies: ["TableProSSHTransport"], + dependencies: ["TableProSSHTransport", "TableProTestSupport"], path: "Tests/TableProSSHTransportTests" ), .testTarget( diff --git a/apple/Packages/TableProCore/Sources/TableProModels/SSHConfiguration.swift b/apple/Packages/TableProCore/Sources/TableProModels/SSHConfiguration.swift index fcec9cab95..f23db39f06 100644 --- a/apple/Packages/TableProCore/Sources/TableProModels/SSHConfiguration.swift +++ b/apple/Packages/TableProCore/Sources/TableProModels/SSHConfiguration.swift @@ -14,11 +14,10 @@ public struct SSHConfiguration: Codable, Hashable, Sendable { public var privateKeyPath: String? public var jumpHosts: [SSHJumpHost] - /// Fields the macOS app stores inside `sshConfigJson` that this model does not use, kept only so - /// they survive an iOS sync round trip. Without them, re-encoding a synced connection on iOS - /// dropped the macOS `enabled`, remote file path, agent socket and TOTP settings, and the macOS - /// app then read the connection back with SSH turned off. Each is optional so a connection this - /// model creates omits it and the macOS side keeps inferring `enabled` from a non-empty host. + // Fields the macOS app stores inside `sshConfigJson` that this model does not use, carried so an + // iOS round trip keeps them. Each is optional, so a configuration this model creates omits it. + // The Mac reads a missing `enabled` as off, so the iOS form writes `macEnabled = true` for a + // tunnel it saves switched on. public var macEnabled: Bool? public var macUseSSHConfig: Bool? public var macAgentSocketPath: String? @@ -125,13 +124,10 @@ public struct SSHConfiguration: Codable, Hashable, Sendable { } } -/// How a hop authenticates, spelled the way the macOS app writes it. A hop is never dialled from -/// iOS, so the value is carried rather than used, and a spelling this build does not know still -/// belongs to the Mac that wrote it: it is held as it arrived and handed back untouched, the way -/// `DatabaseType` holds a type no plugin here implements. The exception is the case names shipped -/// iOS builds wrote (`privateKey`, `sshAgent`), which no macOS build can read: an already-shipped -/// one decodes this raw value strictly, and a `dataCorrupted` thrown inside `jumpHosts` takes the -/// whole SSH configuration with it, and with that the connection. Those normalize. +// How the Mac signs in to a hop, in the macOS spelling. iOS signs in to a hop with a key kept on the +// device, so this is carried, not used, and a spelling this build does not know goes back untouched. +// The case names shipped iOS builds wrote (`privateKey`, `sshAgent`) normalize: a shipped macOS build +// decodes this strictly, and a hop it cannot decode takes the whole SSH configuration with it. public struct SSHJumpAuthMethod: Hashable, Sendable { public static let privateKey = SSHJumpAuthMethod(rawValue: "Private Key") public static let sshAgent = SSHJumpAuthMethod(rawValue: "SSH Agent") @@ -179,6 +175,9 @@ public struct SSHJumpHost: Codable, Hashable, Sendable, Identifiable { public var macAuthMethod: SSHJumpAuthMethod public var macPrivateKeyPath: String + // iOS reads no `~/.ssh/config`, so an unset port is the SSH default here. + public var resolvedPort: Int { port ?? SSHConfiguration.defaultPort } + public init( id: UUID = UUID(), host: String = "", diff --git a/apple/Packages/TableProCore/Sources/TableProSSHTransport/SSHChannelRelay.swift b/apple/Packages/TableProCore/Sources/TableProSSHTransport/SSHChannelRelay.swift index e67d0da8e3..a18e3ce1a6 100644 --- a/apple/Packages/TableProCore/Sources/TableProSSHTransport/SSHChannelRelay.swift +++ b/apple/Packages/TableProCore/Sources/TableProSSHTransport/SSHChannelRelay.swift @@ -172,7 +172,8 @@ public struct SSHChannelRelay { private func sendToLocal(_ buffer: UnsafeMutablePointer, count: Int) -> Bool { var totalSent = 0 while totalSent < count { - let sent = send(localFD, buffer.advanced(by: totalSent), count - totalSent, 0) + // Without the flag, a send to a peer that closed first raises SIGPIPE and kills the app. + let sent = send(localFD, buffer.advanced(by: totalSent), count - totalSent, MSG_NOSIGNAL) if sent <= 0 { return false } totalSent += sent } diff --git a/apple/Packages/TableProCore/Sources/TableProSSHTransport/SSHJumpRelayFence.swift b/apple/Packages/TableProCore/Sources/TableProSSHTransport/SSHJumpRelayFence.swift new file mode 100644 index 0000000000..4a545d1478 --- /dev/null +++ b/apple/Packages/TableProCore/Sources/TableProSSHTransport/SSHJumpRelayFence.swift @@ -0,0 +1,63 @@ +// +// SSHJumpRelayFence.swift +// TableProSSHTransport +// + +import Foundation +import os + +// Cancelling a Swift task does not cancel the GCD block that runs a hop's `SSHChannelRelay`, so +// teardown wakes its descriptors and waits here for its last libssh2 call before freeing the channel +// or session. The stop action and the relay's final close share one lock, so a late stop cannot +// `shutdown` a descriptor number the kernel already handed to someone else. +public final class SSHJumpRelayFence: @unchecked Sendable { + private struct State { + var stopAction: (@Sendable () -> Void)? + var isFinished = false + } + + private let state: OSAllocatedUnfairLock + private let completion = DispatchGroup() + + public init(stopAction: @escaping @Sendable () -> Void) { + state = OSAllocatedUnfairLock(initialState: State(stopAction: stopAction)) + completion.enter() + } + + public var isActive: Bool { + state.withLock { $0.stopAction != nil && !$0.isFinished } + } + + public func stop() { + state.withLock { state in + guard let stopAction = state.stopAction, !state.isFinished else { return } + state.stopAction = nil + stopAction() + } + } + + public func finish(cleanup: @Sendable () -> Void = {}) { + let didFinish = state.withLock { state -> Bool in + guard !state.isFinished else { return false } + state.stopAction = nil + state.isFinished = true + cleanup() + return true + } + if didFinish { + completion.leave() + } + } + + public func wait() { + completion.wait() + } + + public func waitForCompletion() async { + await withCheckedContinuation { continuation in + completion.notify(queue: .global(qos: .utility)) { + continuation.resume() + } + } + } +} diff --git a/apple/Packages/TableProCore/Tests/TableProModelsTests/SSHConfigurationTests.swift b/apple/Packages/TableProCore/Tests/TableProModelsTests/SSHConfigurationTests.swift index 196971d8f3..574baf1528 100644 --- a/apple/Packages/TableProCore/Tests/TableProModelsTests/SSHConfigurationTests.swift +++ b/apple/Packages/TableProCore/Tests/TableProModelsTests/SSHConfigurationTests.swift @@ -139,7 +139,7 @@ struct SSHConfigurationTests { #expect(!text.contains("b3BlbnNzaC1rZXktdjE")) } - @Test("A configuration this model creates omits the macOS-only keys, so the host inference is unchanged") + @Test("A configuration this model creates omits the macOS-only keys") func iosCreatedConfigOmitsMacKeys() throws { let config = SSHConfiguration(host: "prod-1", username: "deploy") let reencoded = try JSONEncoder().encode(config) @@ -170,6 +170,22 @@ struct SSHConfigurationTests { #expect(config.jumpHosts[1].port == 2_222) } + @Test("A hop without a port dials the SSH default") + func portlessHopDialsDefaultPort() throws { + let config = try JSONDecoder().decode(SSHConfiguration.self, from: Data(Self.macJumpHostsJSON.utf8)) + + #expect(config.jumpHosts[0].resolvedPort == 22) + #expect(SSHJumpHost(host: "bastion-1").resolvedPort == 22) + } + + @Test("A hop port the user set is the one dialed") + func setHopPortIsDialed() throws { + let config = try JSONDecoder().decode(SSHConfiguration.self, from: Data(Self.macJumpHostsJSON.utf8)) + + #expect(config.jumpHosts[1].resolvedPort == 2_222) + #expect(SSHJumpHost(host: "bastion-1", port: 2_200).resolvedPort == 2_200) + } + @Test("A hop's auth method and key path survive an iOS round trip") func preservesJumpHostCredentialFields() throws { let decoded = try JSONDecoder().decode(SSHConfiguration.self, from: Data(Self.macJumpHostsJSON.utf8)) diff --git a/apple/Packages/TableProCore/Tests/TableProSSHTransportTests/SSHChannelRelayTests.swift b/apple/Packages/TableProCore/Tests/TableProSSHTransportTests/SSHChannelRelayTests.swift index ae0db540f8..54b7306974 100644 --- a/apple/Packages/TableProCore/Tests/TableProSSHTransportTests/SSHChannelRelayTests.swift +++ b/apple/Packages/TableProCore/Tests/TableProSSHTransportTests/SSHChannelRelayTests.swift @@ -96,6 +96,24 @@ struct SSHChannelRelayTests { #expect(result == .channelClosed) } + @Test("Channel data that arrives after the local peer closed ends the relay instead of raising SIGPIPE") + func channelDataAfterLocalPeerClosed() { + let local = SocketPair() + let transport = SocketPair() + defer { local.close(); transport.close() } + + local.closeB() + var dummy: UInt8 = 1 + _ = Darwin.send(transport.b, &dummy, 1, 0) + + let payload = Data("late".utf8) + let io = FakeChannelIO(actions: [.data(payload)], fallback: .closed) + let result = runRelay(localFD: local.a, transportFD: transport.a, io: io) + + #expect(result == .localClosed) + #expect(io.deliveredCount == payload.count) + } + @Test("Channel data is forwarded to the local socket") func forwardsChannelData() { let local = SocketPair() diff --git a/apple/Packages/TableProCore/Tests/TableProSSHTransportTests/SSHJumpRelayFenceTests.swift b/apple/Packages/TableProCore/Tests/TableProSSHTransportTests/SSHJumpRelayFenceTests.swift new file mode 100644 index 0000000000..af2100fcd3 --- /dev/null +++ b/apple/Packages/TableProCore/Tests/TableProSSHTransportTests/SSHJumpRelayFenceTests.swift @@ -0,0 +1,72 @@ +// +// SSHJumpRelayFenceTests.swift +// TableProSSHTransportTests +// + +import Foundation +import os +import TableProTestSupport +import Testing + +@testable import TableProSSHTransport + +struct SSHJumpRelayFenceTests { + @Test("ProxyJump cleanup waits until its relay no longer uses libssh2 handles") + func proxyJumpCleanupDrainsRelayBeforeFree() async { + let releaseRelay = DispatchSemaphore(value: 0) + let enteredRelay = OSAllocatedUnfairLock(initialState: false) + let relayFinished = OSAllocatedUnfairLock(initialState: false) + let cleanupObservedFinishedRelay = OSAllocatedUnfairLock(initialState: false) + let fence = SSHJumpRelayFence { + releaseRelay.signal() + } + + Thread.detachNewThread { + enteredRelay.withLock { $0 = true } + releaseRelay.wait() + relayFinished.withLock { $0 = true } + fence.finish() + } + + guard (try? await waitUntil(timeout: .seconds(1)) { enteredRelay.withLock { $0 } }) != nil else { + releaseRelay.signal() + Issue.record("The ProxyJump relay did not start") + return + } + + let teardown = await BoundedCall.resultOnItsOwnThread( + within: .seconds(2), + onDeadline: { releaseRelay.signal() }, + of: { + fence.stop() + fence.wait() + cleanupObservedFinishedRelay.withLock { value in + value = relayFinished.withLock { $0 } + } + return true + } + ) + + #expect(teardown == true) + #expect(cleanupObservedFinishedRelay.withLock { $0 }) + #expect(!fence.isActive) + } + + @Test("A completed ProxyJump relay disarms late descriptor interruption") + func proxyJumpCompletionDisarmsLateStop() { + let stopCount = OSAllocatedUnfairLock(initialState: 0) + let released = OSAllocatedUnfairLock(initialState: false) + let fence = SSHJumpRelayFence { + stopCount.withLock { $0 += 1 } + } + + fence.finish { + released.withLock { $0 = true } + } + fence.stop() + + #expect(released.withLock { $0 }) + #expect(stopCount.withLock { $0 } == 0) + #expect(!fence.isActive) + } +} diff --git a/apple/TablePro/Core/SSH/LibSSH2JumpRelay.swift b/apple/TablePro/Core/SSH/LibSSH2JumpRelay.swift new file mode 100644 index 0000000000..cf66fe5429 --- /dev/null +++ b/apple/TablePro/Core/SSH/LibSSH2JumpRelay.swift @@ -0,0 +1,62 @@ +// +// LibSSH2JumpRelay.swift +// TablePro +// +// Compiled into the macOS app and, by file reference, into the iOS app, which defaults to +// MainActor isolation. Every declaration states its own isolation for that reason; the iOS +// build is the check. +// + +import Foundation + +import TableProSSHTransport + +nonisolated internal enum LibSSH2JumpRelay { + // The relay is the only thing that touches these once it starts, which the compiler cannot see + // through an OpaquePointer. + nonisolated private struct Handles: @unchecked Sendable { + let channel: OpaquePointer + let session: OpaquePointer + } + + static func start( + channel: OpaquePointer, + localFD: Int32, + transportFD: Int32, + session: OpaquePointer, + sessionQueue: DispatchQueue + ) -> SSHJumpRelayFence { + let relayQueue = DispatchQueue( + label: "com.TablePro.ssh.hop-relay", + qos: .utility + ) + let handles = Handles(channel: channel, session: session) + let fence = SSHJumpRelayFence { + shutdown(localFD, SHUT_RDWR) + shutdown(transportFD, SHUT_RDWR) + } + relayQueue.async { + defer { + fence.finish { + shutdown(localFD, SHUT_RDWR) + Darwin.close(localFD) + } + } + + let relay = SSHChannelRelay( + localFD: localFD, + transportFD: transportFD, + channelIO: LibSSH2ChannelIO( + channel: handles.channel, + session: handles.session, + sessionQueue: sessionQueue + ), + bufferSize: 32_768, + isActive: { fence.isActive } + ) + + _ = relay.run() + } + return fence + } +} diff --git a/apple/TablePro/Core/SSH/LibSSH2TunnelFactory.swift b/apple/TablePro/Core/SSH/LibSSH2TunnelFactory.swift index f0ca6cb5d8..1145f46caa 100644 --- a/apple/TablePro/Core/SSH/LibSSH2TunnelFactory.swift +++ b/apple/TablePro/Core/SSH/LibSSH2TunnelFactory.swift @@ -19,67 +19,6 @@ internal struct SSHTunnelCredentials: Sendable { let keyboardInteractivePromptProvider: (any KeyboardInteractivePromptProvider)? } -/// Owns the lifetime boundary between a ProxyJump relay and the libssh2 handles it uses. -/// -/// Cancelling a Swift task does not cancel the GCD block that runs `SSHChannelRelay`. Teardown must -/// therefore wake its descriptors and wait for that block to make its final serialized libssh2 call -/// before freeing the channel or session. The stop action and the relay's final descriptor close are -/// mutually exclusive under one lock, so a late stop cannot `shutdown` a reused descriptor number. -internal final class SSHJumpRelayFence: @unchecked Sendable { - private struct State { - var stopAction: (@Sendable () -> Void)? - var isFinished = false - } - - private let state: OSAllocatedUnfairLock - private let completion = DispatchGroup() - - internal init(stopAction: @escaping @Sendable () -> Void) { - state = OSAllocatedUnfairLock(initialState: State(stopAction: stopAction)) - completion.enter() - } - - internal var isActive: Bool { - state.withLock { $0.stopAction != nil && !$0.isFinished } - } - - internal func stop() { - state.withLock { state in - guard let stopAction = state.stopAction, !state.isFinished else { return } - state.stopAction = nil - stopAction() - } - } - - /// Disarms late stops and releases the completion fence only after the relay-owned local - /// descriptor has been closed. `cleanup` runs under the same lock as `stopAction` so those two - /// descriptor operations cannot cross. - internal func finish(cleanup: @Sendable () -> Void = {}) { - let didFinish = state.withLock { state -> Bool in - guard !state.isFinished else { return false } - state.stopAction = nil - state.isFinished = true - cleanup() - return true - } - if didFinish { - completion.leave() - } - } - - internal func wait() { - completion.wait() - } - - internal func waitForCompletion() async { - await withCheckedContinuation { continuation in - completion.notify(queue: .global(qos: .utility)) { - continuation.resume() - } - } - } -} - /// Creates fully-connected and authenticated SSH tunnels using libssh2. internal enum LibSSH2TunnelFactory { internal static let logger = Logger(subsystem: "com.TablePro", category: "LibSSH2TunnelFactory") @@ -344,10 +283,10 @@ internal enum LibSSH2TunnelFactory { qos: .utility ) - let relay = startChannelRelay( + let relay = LibSSH2JumpRelay.start( channel: channel, - socketFD: fds[0], - sshSocketFD: currentSocketFD, + localFD: fds[0], + transportFD: currentSocketFD, session: currentSession, sessionQueue: hopSessionQueue ) @@ -1101,56 +1040,6 @@ internal enum LibSSH2TunnelFactory { .tunnel("\(host):\(port)") } - /// The libssh2 handles the relay task takes ownership of. The relay is the only thing that - /// touches them once it starts, which is what the compiler cannot see through an OpaquePointer. - private struct RelayHandles: @unchecked Sendable { - let channel: OpaquePointer - let session: OpaquePointer - } - - /// Start a relay task that copies data between a channel and a socketpair fd. - /// libssh2 calls use `sessionQueue.sync` for thread safety; I/O loop runs on a concurrent queue. - private static func startChannelRelay( - channel: OpaquePointer, - socketFD: Int32, - sshSocketFD: Int32, - session: OpaquePointer, - sessionQueue: DispatchQueue - ) -> SSHJumpRelayFence { - let relayQueue = DispatchQueue( - label: "com.TablePro.ssh.hop-relay", - qos: .utility - ) - let handles = RelayHandles(channel: channel, session: session) - let fence = SSHJumpRelayFence { - shutdown(socketFD, SHUT_RDWR) - shutdown(sshSocketFD, SHUT_RDWR) - } - relayQueue.async { - defer { - fence.finish { - shutdown(socketFD, SHUT_RDWR) - Darwin.close(socketFD) - } - } - - let relay = SSHChannelRelay( - localFD: socketFD, - transportFD: sshSocketFD, - channelIO: LibSSH2ChannelIO( - channel: handles.channel, - session: handles.session, - sessionQueue: sessionQueue - ), - bufferSize: 32_768, - isActive: { fence.isActive } - ) - - _ = relay.run() - } - return fence - } - // MARK: - Local Socket private static func bindListenSocket(port: Int) throws -> Int32 { diff --git a/apple/TableProMobile/TableProMobile/Helpers/AppError.swift b/apple/TableProMobile/TableProMobile/Helpers/AppError.swift index f1ec448ded..f045e875cd 100644 --- a/apple/TableProMobile/TableProMobile/Helpers/AppError.swift +++ b/apple/TableProMobile/TableProMobile/Helpers/AppError.swift @@ -114,6 +114,10 @@ nonisolated enum ErrorClassifier { return connectionFailure(connectionError) } + if let tunnelError = error as? SSHTunnelError, let classified = jumpHostFailure(tunnelError) { + return classified + } + if let redisError = error as? RedisError, let classified = redisFailure(redisError, context: context) { return classified } @@ -234,6 +238,31 @@ nonisolated enum ErrorClassifier { } } + // By case, because a translated message may hold none of the words the substring rules look for. + private static func jumpHostFailure(_ error: SSHTunnelError) -> AppError? { + let recovery: String + switch error { + case .usernameMissing: + recovery = String(localized: "Edit the connection and enter the username under SSH Server or Jump Hosts.") + case .jumpHostNeedsKey, .jumpHostSharedKeyMissing: + recovery = String(localized: "Edit the connection and set a key for this jump host under Jump Hosts.") + case .jumpHostFailed: + recovery = String(localized: "Check this jump host's address, port, username and key under Jump Hosts.") + case .jumpHostKeysUnreadable: + recovery = String(localized: "Try again. If it keeps failing, update TablePro on this device.") + case .connectionFailed, .handshakeFailed, .authenticationFailed, .noAvailablePort, .channelOpenFailed, + .hostKeyRejected, .hostKeyUnverified, .tunnelClosed: + return nil + } + return AppError( + category: .ssh, + title: String(localized: "SSH Tunnel Failed"), + message: error.localizedDescription, + recovery: recovery, + underlying: error + ) + } + /// A Redis reply is read by its error class, never by its words, because the server echoes the /// user's own arguments back: `FOO password` answers `ERR unknown command 'FOO', with args /// beginning with: 'password'`. A transport or setup failure returns nil and is read as text. diff --git a/apple/TableProMobile/TableProMobile/Helpers/ConnectionFormEdits.swift b/apple/TableProMobile/TableProMobile/Helpers/ConnectionFormEdits.swift index 6a80da9f49..59e88761de 100644 --- a/apple/TableProMobile/TableProMobile/Helpers/ConnectionFormEdits.swift +++ b/apple/TableProMobile/TableProMobile/Helpers/ConnectionFormEdits.swift @@ -3,12 +3,20 @@ import TableProModels import TableProOracleCore nonisolated struct ConnectionFormEdits: Equatable, Sendable { + nonisolated struct JumpHostEdit: Equatable, Sendable, Identifiable { + var id: UUID + var host: String + var port: Int? + var username: String + } + nonisolated struct SSHTunnel: Equatable, Sendable { var host: String var port: Int? var username: String var authMethod: SSHConfiguration.SSHAuthMethod var privateKeyPath: String? + var jumpHosts: [JumpHostEdit] = [] } nonisolated struct OracleOptions: Equatable, Sendable { @@ -100,15 +108,65 @@ nonisolated struct ConnectionFormEdits: Equatable, Sendable { if changed(\.username) { configuration.username = sshTunnel.username } if changed(\.authMethod) { configuration.authMethod = sshTunnel.authMethod } if changed(\.privateKeyPath) { configuration.privateKeyPath = sshTunnel.privateKeyPath } + if changed(\.jumpHosts) { + configuration.jumpHosts = Self.rebuiltJumpHosts( + sshTunnel.jumpHosts, + over: configuration.jumpHosts, + changedSince: opening?.jumpHosts + ) + } + // The Mac reads a missing `enabled` as off. if opening == nil { connection.sshEnabled = true - if configuration.macEnabled != nil { - configuration.macEnabled = true - } + configuration.macEnabled = true + } else if configuration.macEnabled == nil, connection.sshEnabled { + configuration.macEnabled = true } connection.sshConfiguration = configuration } + // Each hop keeps the record's Mac credential fields and takes only the fields the form changed. + // Where the form left the chain alone, the record's chain wins, since another device may have + // saved it while the form was open: a field edit keeps the record's order, a hop removed there + // stays removed, and a hop added there keeps its place after the hop before it. + private static func rebuiltJumpHosts( + _ edits: [JumpHostEdit], + over current: [SSHJumpHost], + changedSince opening: [JumpHostEdit]? + ) -> [SSHJumpHost] { + let currentHops = Dictionary(current.map { ($0.id, $0) }, uniquingKeysWith: { first, _ in first }) + let openingHops = Dictionary((opening ?? []).map { ($0.id, $0) }, uniquingKeysWith: { first, _ in first }) + let editedHops = Dictionary(edits.map { ($0.id, $0) }, uniquingKeysWith: { first, _ in first }) + + func applyingEdit(to hop: SSHJumpHost) -> SSHJumpHost { + guard let edit = editedHops[hop.id] else { return hop } + var hop = hop + let opened = openingHops[hop.id] + if differs(\JumpHostEdit.host, from: opened, to: edit) { hop.host = edit.host } + if differs(\JumpHostEdit.port, from: opened, to: edit) { hop.port = edit.port } + if differs(\JumpHostEdit.username, from: opened, to: edit) { hop.username = edit.username } + return hop + } + + if let opening, edits.map(\.id) == opening.map(\.id) { + return current.map(applyingEdit(to:)) + } + + var merged = edits.compactMap { edit -> SSHJumpHost? in + if let hop = currentHops[edit.id] { return applyingEdit(to: hop) } + guard openingHops[edit.id] == nil else { return nil } + return SSHJumpHost(id: edit.id, host: edit.host, port: edit.port, username: edit.username) + } + guard opening != nil else { return merged } + for (index, hop) in current.enumerated() where openingHops[hop.id] == nil && editedHops[hop.id] == nil { + let anchor = current[..(_ field: KeyPath) -> Bool { diff --git a/apple/TableProMobile/TableProMobile/Localizable.xcstrings b/apple/TableProMobile/TableProMobile/Localizable.xcstrings index ca1c8643d8..454877a741 100644 --- a/apple/TableProMobile/TableProMobile/Localizable.xcstrings +++ b/apple/TableProMobile/TableProMobile/Localizable.xcstrings @@ -1516,6 +1516,9 @@ } } } + }, + "Add Jump Host" : { + }, "Add multiple rows to a table on a saved connection. Provide the rows as a JSON array, CSV text, or a file." : { "localizations" : { @@ -3016,6 +3019,9 @@ "Chat" : { "extractionState" : "manual", "generatesSymbol" : false + }, + "Check this jump host's address, port, username and key under Jump Hosts." : { + }, "Check your %@ username and password." : { "localizations" : { @@ -6416,6 +6422,12 @@ } } } + }, + "Edit the connection and enter the username under SSH Server or Jump Hosts." : { + + }, + "Edit the connection and set a key for this jump host under Jump Hosts." : { + }, "Empty String" : { "localizations" : { @@ -9460,6 +9472,18 @@ "JSON" : { "extractionState" : "manual", "generatesSymbol" : false + }, + "Jump Hosts" : { + + }, + "Jump host %1$@ failed: %2$@" : { + + }, + "Jump host %@ has no key on this device." : { + + }, + "Jump host %@ uses the SSH server's key, which is not on this device." : { + }, "Jump host key not found: %@" : { "extractionState" : "manual", @@ -9490,6 +9514,9 @@ } } } + }, + "Jump hosts are connected in order before reaching the SSH server above." : { + }, "Keep Connections at the Top" : { "localizations" : { @@ -9611,6 +9638,12 @@ }, "Key Not Found" : { + }, + "Keyboard Interactive" : { + + }, + "Keyboard Interactive is set on the Mac and is not available on iPhone and iPad." : { + }, "Keychain Warning" : { "localizations" : { @@ -10930,6 +10963,9 @@ } } } + }, + "New Jump Host" : { + }, "New Subgroup" : { "localizations" : { @@ -11187,6 +11223,9 @@ } } } + }, + "No key on this device" : { + }, "No Foreign Keys" : { "localizations" : { @@ -11469,6 +11508,9 @@ } } } + }, + "No SSH username is set for %@." : { + }, "No row found in %@ where %@ = '%@'" : { "localizations" : { @@ -12587,6 +12629,9 @@ } } } + }, + "Own key" : { + }, "out-of-band check" : { "extractionState" : "manual", @@ -15052,6 +15097,12 @@ } } } + }, + "Same Key as SSH Server" : { + + }, + "Same key as SSH server" : { + }, "Sample Database" : { "localizations" : { @@ -15654,6 +15705,9 @@ } } } + }, + "Set to SSH Agent on the Mac. iPhone and iPad sign in with a private key." : { + }, "Set up a new database connection" : { "extractionState" : "stale", @@ -18122,6 +18176,9 @@ } } } + }, + "That file is not a text private key." : { + }, "That file is not a valid XLSX workbook." : { "extractionState" : "manual", @@ -18586,6 +18643,12 @@ } } } + }, + "The jump host keys on this device could not be read. Close the form and open it again to change them." : { + + }, + "The jump host keys on this device could not be read: %@" : { + }, "The JSON array must contain only objects." : { "localizations" : { @@ -19049,6 +19112,9 @@ } } } + }, + "The SSH server does not sign in with a private key, so this jump host needs its own key." : { + }, "The password is longer than the 128 bytes FreeTDS takes." : { @@ -20903,6 +20969,9 @@ } } } + }, + "Try again. If it keeps failing, update TablePro on this device." : { + }, "Turn Off iCloud Sync" : { diff --git a/apple/TableProMobile/TableProMobile/Platform/ConnectionSecretKind.swift b/apple/TableProMobile/TableProMobile/Platform/ConnectionSecretKind.swift index f40ad25abc..fa0ce8e25f 100644 --- a/apple/TableProMobile/TableProMobile/Platform/ConnectionSecretKind.swift +++ b/apple/TableProMobile/TableProMobile/Platform/ConnectionSecretKind.swift @@ -5,6 +5,7 @@ nonisolated enum ConnectionSecretKind: CaseIterable, Sendable { case sshPassword case keyPassphrase case sshPrivateKey + case jumpHostKeys var prefix: String { switch self { @@ -12,13 +13,14 @@ nonisolated enum ConnectionSecretKind: CaseIterable, Sendable { case .sshPassword: "com.TablePro.sshpassword." case .keyPassphrase: "com.TablePro.keypassphrase." case .sshPrivateKey: "com.TablePro.sshkeydata." + case .jumpHostKeys: "com.TablePro.jumphostkeys." } } var isSweptWhenOrphaned: Bool { switch self { case .password, .sshPassword, .keyPassphrase: true - case .sshPrivateKey: false + case .sshPrivateKey, .jumpHostKeys: false } } diff --git a/apple/TableProMobile/TableProMobile/SSH/IOSSSHProvider.swift b/apple/TableProMobile/TableProMobile/SSH/IOSSSHProvider.swift index 57a32cfa08..444cdb62b5 100644 --- a/apple/TableProMobile/TableProMobile/SSH/IOSSSHProvider.swift +++ b/apple/TableProMobile/TableProMobile/SSH/IOSSSHProvider.swift @@ -1,8 +1,11 @@ import Foundation +import os import TableProDatabase import TableProModels final class IOSSSHProvider: SSHProvider, @unchecked Sendable { + private static let logger = Logger(subsystem: "com.TablePro", category: "IOSSSHProvider") + private let tunnelStore = TunnelStore() private let secureStore: SecureStore private let container: AppContainerPaths @@ -21,12 +24,14 @@ final class IOSSSHProvider: SSHProvider, @unchecked Sendable { ) async throws -> TableProDatabase.SSHTunnel { var resolvedConfig = config resolvedConfig.privateKeyPath = config.privateKeyPath.map(container.localPath(forStoredPath:)) + let hopCredentials = try jumpHostCredentials(for: connectionId, hops: config.jumpHosts) let tunnel = try await SSHTunnelFactory.create( config: resolvedConfig, remoteHost: remoteHost, remotePort: remotePort, credentials: SSHTunnelCredentials(connectionId: connectionId, secureStore: secureStore), + jumpHostCredentials: hopCredentials, prompter: prompter ) @@ -36,6 +41,18 @@ final class IOSSSHProvider: SSHProvider, @unchecked Sendable { return TableProDatabase.SSHTunnel(id: tunnelId, localHost: "127.0.0.1", localPort: tunnel.localPort) } + // A key that could not be read is not a missing key: telling the user to set one would be wrong. + private func jumpHostCredentials(for connectionId: UUID, hops: [SSHJumpHost]) throws -> [UUID: JumpHostCredential] { + guard !hops.isEmpty else { return [:] } + do { + return try JumpHostCredentialStore.load(for: connectionId, in: secureStore) + } catch { + let privateDescription = String(reflecting: error) + Self.logger.error("Reading the jump host keys failed: \(privateDescription, privacy: .private)") + throw SSHTunnelError.jumpHostKeysUnreadable(error.localizedDescription) + } + } + func closeTunnel(for connectionId: UUID) async throws { for tunnel in await tunnelStore.removeAll(connectionId: connectionId) { tunnel.close() diff --git a/apple/TableProMobile/TableProMobile/SSH/JumpHostCredential.swift b/apple/TableProMobile/TableProMobile/SSH/JumpHostCredential.swift new file mode 100644 index 0000000000..c7ae3ebaf7 --- /dev/null +++ b/apple/TableProMobile/TableProMobile/SSH/JumpHostCredential.swift @@ -0,0 +1,71 @@ +import Foundation +import TableProDatabase +import TableProModels + +nonisolated enum JumpHostCredential: Codable, Equatable, Sendable { + case sshServerKey + case privateKey(key: String, passphrase: String?) +} + +// One Keychain item per connection, a map from hop id to credential, so Duplicate, delete and the +// sync purge reach hop keys through the connection id alone. +nonisolated enum JumpHostCredentialStore { + static func load(for connectionId: UUID, in secureStore: any SecureStore) throws -> [UUID: JumpHostCredential] { + guard let stored = try secureStore.retrieve(forKey: account(for: connectionId)), !stored.isEmpty else { + return [:] + } + return try decode(stored) + } + + static func save( + _ credentials: [UUID: JumpHostCredential], + for connectionId: UUID, + in secureStore: any SecureStore + ) throws { + guard !credentials.isEmpty else { + try secureStore.delete(forKey: account(for: connectionId)) + return + } + try secureStore.store(encode(credentials), forKey: account(for: connectionId)) + } + + static func encode(_ credentials: [UUID: JumpHostCredential]) throws -> String { + let keyed = Dictionary(uniqueKeysWithValues: credentials.map { ($0.key.uuidString, $0.value) }) + let encoder = JSONEncoder() + encoder.outputFormatting = .sortedKeys + return String(decoding: try encoder.encode(keyed), as: UTF8.self) + } + + // A .tablepro hop carries no id, so a replaced connection's hops would lose their keys. Each + // incoming hop takes the id of the first unused stored hop with the same host, port and username. + static func keepingHopIds(from stored: SSHConfiguration?, into incoming: SSHConfiguration?) -> SSHConfiguration? { + guard let stored, var merged = incoming else { return incoming } + var unused = stored.jumpHosts + for index in merged.jumpHosts.indices { + let hop = merged.jumpHosts[index] + guard let match = unused.firstIndex(where: { + $0.host == hop.host && $0.resolvedPort == hop.resolvedPort && $0.username == hop.username + }) else { continue } + merged.jumpHosts[index].id = unused.remove(at: match).id + } + return merged + } + + private static func decode(_ stored: String) throws -> [UUID: JumpHostCredential] { + let keyed = try JSONDecoder().decode([String: JumpHostCredential].self, from: Data(stored.utf8)) + var credentials: [UUID: JumpHostCredential] = [:] + for (key, credential) in keyed { + guard let hopId = UUID(uuidString: key) else { + throw DecodingError.dataCorrupted( + DecodingError.Context(codingPath: [], debugDescription: "A jump host key is not stored under a hop id") + ) + } + credentials[hopId] = credential + } + return credentials + } + + private static func account(for connectionId: UUID) -> String { + ConnectionSecretKind.jumpHostKeys.account(for: connectionId) + } +} diff --git a/apple/TableProMobile/TableProMobile/SSH/SSHAuthMethod+Device.swift b/apple/TableProMobile/TableProMobile/SSH/SSHAuthMethod+Device.swift new file mode 100644 index 0000000000..8a1abd5389 --- /dev/null +++ b/apple/TableProMobile/TableProMobile/SSH/SSHAuthMethod+Device.swift @@ -0,0 +1,11 @@ +import TableProModels + +nonisolated extension SSHConfiguration.SSHAuthMethod { + // iOS has no agent, so an agent tunnel signs in with a key on this device; the synced value stays the Mac's. + var onDevice: SSHConfiguration.SSHAuthMethod { + switch self { + case .sshAgent: .privateKey + case .password, .privateKey, .keyboardInteractive, .none: self + } + } +} diff --git a/apple/TableProMobile/TableProMobile/SSH/SSHJumpChain.swift b/apple/TableProMobile/TableProMobile/SSH/SSHJumpChain.swift new file mode 100644 index 0000000000..f9f6264b47 --- /dev/null +++ b/apple/TableProMobile/TableProMobile/SSH/SSHJumpChain.swift @@ -0,0 +1,39 @@ +import CLibSSH2 +import Foundation +import TableProSSHTransport + +// `@unchecked`: the libssh2 handles are touched only on `sessionQueue`, and only by the relay until +// its fence has finished. +nonisolated struct SSHJumpHop: @unchecked Sendable { + let session: OpaquePointer + let socketFD: Int32 + let channel: OpaquePointer + let relay: SSHJumpRelayFence + let sessionQueue: DispatchQueue +} + +nonisolated enum SSHJumpChain { + // The Mac's order: wake every relay, wait until none can reach its handles, then free them. + static func tearDown(_ hops: [SSHJumpHop], free: (SSHJumpHop) -> Void = SSHJumpChain.freeHandles(_:)) async { + let ordered = Array(hops.reversed()) + for hop in ordered { + hop.relay.stop() + shutdown(hop.socketFD, SHUT_RDWR) + } + for hop in ordered { + await hop.relay.waitForCompletion() + } + for hop in ordered { + free(hop) + } + } + + static func freeHandles(_ hop: SSHJumpHop) { + hop.sessionQueue.sync { + libssh2_channel_free(hop.channel) + tablepro_libssh2_session_disconnect(hop.session, "Closing tunnel") + libssh2_session_free(hop.session) + } + Darwin.close(hop.socketFD) + } +} diff --git a/apple/TableProMobile/TableProMobile/SSH/SSHTunnel.swift b/apple/TableProMobile/TableProMobile/SSH/SSHTunnel.swift index afc225b59a..35d0f9b649 100644 --- a/apple/TableProMobile/TableProMobile/SSH/SSHTunnel.swift +++ b/apple/TableProMobile/TableProMobile/SSH/SSHTunnel.swift @@ -23,6 +23,7 @@ nonisolated final class SSHTunnel: @unchecked Sendable { private let session: OpaquePointer private let socketFD: Int32 private let listenFD: Int32 + private let jumpHops: [SSHJumpHop] /// Serial: every libssh2 call on this session. libssh2 is not thread-safe per session. private let sessionQueue: DispatchQueue @@ -66,13 +67,15 @@ nonisolated final class SSHTunnel: @unchecked Sendable { socketFD: Int32, listenFD: Int32, localPort: Int, - sessionQueue: DispatchQueue + sessionQueue: DispatchQueue, + jumpHops: [SSHJumpHop] = [] ) { self.session = session self.socketFD = socketFD self.listenFD = listenFD self.localPort = localPort self.sessionQueue = sessionQueue + self.jumpHops = jumpHops let label = UUID().uuidString self.relayQueue = DispatchQueue(label: "com.TablePro.ssh.relay.\(label)", qos: .utility, attributes: .concurrent) self.acceptQueue = DispatchQueue(label: "com.TablePro.ssh.accept.\(label)", qos: .utility) @@ -153,15 +156,10 @@ nonisolated final class SSHTunnel: @unchecked Sendable { performTeardown() } - /// Breaks every blocking wait first, then frees the session and the descriptors only once - /// every task that could still be polling them has exited. `shutdown` unblocks a poll at - /// once without releasing the descriptor number, which another thread would otherwise be - /// free to receive from the kernel and poll by mistake. `shutdown` does not wake a poll on a - /// listening socket on Darwin, so the accept loop ends on the latch instead, inside one - /// `acceptPollTimeoutMs`, and its descriptor closes once it has. - /// - /// The relays are waited on after the accept loop, not alongside it, because the accept loop - /// is the only thing that starts one: once it has ended, the group can only empty. + // `shutdown` wakes every poll without giving the descriptor number back; nothing is freed or + // closed until whatever could poll it has exited. Darwin's `shutdown` does not wake a listening + // socket, so the accept loop ends on the latch. It is the only thing that starts a relay, so the + // relays are waited on after it. The hops go last, in the Mac's order. private func performTeardown() { forwardingTask?.cancel() keepAliveTask?.cancel() @@ -174,6 +172,7 @@ nonisolated final class SSHTunnel: @unchecked Sendable { let session = self.session let socketFD = self.socketFD let listenFD = self.listenFD + let jumpHops = self.jumpHops let localPort = self.localPort let forwardingTask = self.forwardingTask let keepAliveTask = self.keepAliveTask @@ -191,6 +190,7 @@ nonisolated final class SSHTunnel: @unchecked Sendable { tablepro_libssh2_session_disconnect(session, "Closing tunnel") libssh2_session_free(session) } + await SSHJumpChain.tearDown(jumpHops) Self.logger.info("Tunnel closed (local port \(localPort))") } diff --git a/apple/TableProMobile/TableProMobile/SSH/SSHTunnelError.swift b/apple/TableProMobile/TableProMobile/SSH/SSHTunnelError.swift index 5c935c1caa..e2311993f5 100644 --- a/apple/TableProMobile/TableProMobile/SSH/SSHTunnelError.swift +++ b/apple/TableProMobile/TableProMobile/SSH/SSHTunnelError.swift @@ -8,7 +8,11 @@ nonisolated enum SSHTunnelError: Error, LocalizedError, Equatable, Sendable { case channelOpenFailed(String) case hostKeyRejected(String) case hostKeyUnverified(String) - case jumpHostsUnsupported + case usernameMissing(host: String) + case jumpHostNeedsKey(host: String) + case jumpHostSharedKeyMissing(host: String) + case jumpHostFailed(host: String, reason: String) + case jumpHostKeysUnreadable(String) case tunnelClosed var errorDescription: String? { @@ -20,11 +24,19 @@ nonisolated enum SSHTunnelError: Error, LocalizedError, Equatable, Sendable { case .channelOpenFailed(let msg): return "SSH channel open failed: \(msg)" case .hostKeyRejected(let msg): return msg case .hostKeyUnverified(let msg): return msg - case .jumpHostsUnsupported: - return String(localized: """ - This connection goes through an SSH jump host, which TablePro on iPhone and iPad \ - cannot dial. Open it in TablePro on the Mac. - """) + case .usernameMissing(let host): + return String(format: String(localized: "No SSH username is set for %@."), host) + case .jumpHostNeedsKey(let host): + return String(format: String(localized: "Jump host %@ has no key on this device."), host) + case .jumpHostSharedKeyMissing(let host): + return String( + format: String(localized: "Jump host %@ uses the SSH server's key, which is not on this device."), + host + ) + case .jumpHostFailed(let host, let reason): + return String(format: String(localized: "Jump host %1$@ failed: %2$@"), host, reason) + 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" } } diff --git a/apple/TableProMobile/TableProMobile/SSH/SSHTunnelFactory.swift b/apple/TableProMobile/TableProMobile/SSH/SSHTunnelFactory.swift index 7454a1ae7f..b9e50561fb 100644 --- a/apple/TableProMobile/TableProMobile/SSH/SSHTunnelFactory.swift +++ b/apple/TableProMobile/TableProMobile/SSH/SSHTunnelFactory.swift @@ -10,55 +10,195 @@ nonisolated enum SSHTunnelFactory { return true }() - /// Builds an authenticated, forwarding tunnel, or releases everything it built. - /// - /// The `defer` guards the whole function body rather than a `do`/`catch` region, so a step - /// appended later cannot escape it: eight throwing steps ran unguarded before, and five failed - /// handshakes leaked ten descriptors, each an established TCP socket to the SSH server. The - /// flag is the repo's own hand-over idiom, the `adopted`/`defer` pair both PostgreSQL connect - /// paths use. - /// - /// `Stages` is a protocol so the guarantee is testable with no SSH server in reach: a spy that - /// throws at any step asserts exactly one `discard()`. + private struct HopKey { + let source: SSHTunnelCredentials.PrivateKeySource + let passphrase: String? + } + + private enum Role { + case jumpHost(HopKey) + case sshServer + } + + private struct Target { + let host: String + let port: Int + let username: String + let role: Role + } + + // The `defer` guards the whole body, so a step added later cannot leak what was built: unguarded + // steps once left two descriptors open per failed handshake. `Stages` lets a spy test that. static func create( config: SSHConfiguration, remoteHost: String, remotePort: Int, credentials: SSHTunnelCredentials, + jumpHostCredentials: [UUID: JumpHostCredential] = [:], prompter: (any ConnectionPrompter)?, hostKeyStore: HostKeyStore = .shared, + ensureLocalNetworkAccess: @Sendable (String) async throws -> Void = { host in + try await LocalNetworkPermission.shared.ensureAccess(for: host) + }, makeStages: @Sendable () -> Stages = { LibSSH2TunnelStages() } ) async throws -> Stages.Tunnel { _ = initialized - guard config.jumpHosts.isEmpty else { throw SSHTunnelError.jumpHostsUnsupported } + try requireValidPort(remotePort, of: remoteHost) + let targets = try dialOrder(config: config, credentials: credentials, jumpHostCredentials: jumpHostCredentials) - try await LocalNetworkPermission.shared.ensureAccess(for: config.host) + // Only the first host is dialed from the device; every later one is opened by the hop before it. + try await ensureLocalNetworkAccess(targets[0].host) let stages = makeStages() var handedOver = false defer { if !handedOver { stages.discard() } } - try await stages.connect(host: config.host, port: config.resolvedPort) + for (index, target) in targets.enumerated() { + if index == 0 { + try await failing(as: target) { + try await stages.connect(host: target.host, port: target.port) + } + } else { + try await failing(as: targets[index - 1]) { + try await stages.jump(toHost: target.host, port: target.port) + } + } + try await failing(as: target) { + try await signIn( + to: target, + stages: stages, + config: config, + credentials: credentials, + prompter: prompter, + hostKeyStore: hostKeyStore + ) + } + } + + let tunnel = try await stages.beginForwarding( + destination: .tcp(host: remoteHost, port: remotePort) + ) + handedOver = true + return tunnel + } + + // Every check that needs no network runs before anything is dialed, so a hop without a key on + // this device is named at once instead of after the hops before it have signed in. + private static func dialOrder( + config: SSHConfiguration, + credentials: SSHTunnelCredentials, + jumpHostCredentials: [UUID: JumpHostCredential] + ) throws -> [Target] { + var targets: [Target] = [] + for hop in config.jumpHosts { + guard hasUsername(hop.username) else { throw SSHTunnelError.usernameMissing(host: hop.host) } + try requireValidPort(hop.resolvedPort, of: hop.host) + let key = try hopKey(for: hop, credential: jumpHostCredentials[hop.id], config: config, credentials: credentials) + targets.append(Target(host: hop.host, port: hop.resolvedPort, username: hop.username, role: .jumpHost(key))) + } + guard hasUsername(config.username) else { throw SSHTunnelError.usernameMissing(host: config.host) } + try requireValidPort(config.resolvedPort, of: config.host) + targets.append(Target(host: config.host, port: config.resolvedPort, username: config.username, role: .sshServer)) + return targets + } + + // A channel takes the port as an Int32, so a larger value would trap instead of failing. + private static func requireValidPort(_ port: Int, of host: String) throws { + guard (1...65_535).contains(port) else { + throw SSHTunnelError.connectionFailed( + "Port \(port) of \(host) is out of range. Use a value between 1 and 65535." + ) + } + } + + private static func hopKey( + for hop: SSHJumpHost, + credential: JumpHostCredential?, + config: SSHConfiguration, + credentials: SSHTunnelCredentials + ) throws -> HopKey { + switch credential { + case .some(.privateKey(let key, let passphrase)) where !key.isEmpty: + return HopKey(source: .inMemory(key), passphrase: passphrase.flatMap { $0.isEmpty ? nil : $0 }) + case .some(.sshServerKey): + guard let source = serverKeyOnDevice(config: config, credentials: credentials) else { + throw SSHTunnelError.jumpHostSharedKeyMissing(host: hop.host) + } + return HopKey(source: source, passphrase: credentials.keyPassphrase) + case .some(.privateKey), .none: + throw SSHTunnelError.jumpHostNeedsKey(host: hop.host) + } + } + + // A synced Mac path reaches here untouched, and it names a file only the Mac has. + private static func serverKeyOnDevice( + config: SSHConfiguration, + credentials: SSHTunnelCredentials + ) -> SSHTunnelCredentials.PrivateKeySource? { + guard config.authMethod.onDevice == .privateKey else { return nil } + let source = credentials.privateKeySource(keyPath: config.privateKeyPath) + switch source { + case .inMemory: + return source + case .file(let path): + return FileManager.default.fileExists(atPath: (path as NSString).expandingTildeInPath) ? source : nil + case .missing: + return nil + } + } + + private static func hasUsername(_ username: String) -> Bool { + !username.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + } + + private static func signIn( + to target: Target, + stages: Stages, + config: SSHConfiguration, + credentials: SSHTunnelCredentials, + prompter: (any ConnectionPrompter)?, + hostKeyStore: HostKeyStore + ) async throws { try await stages.handshake() let presentedKey = try await stages.hostKey() try await HostKeyVerifier.verify( keyData: presentedKey.keyData, keyType: presentedKey.keyType, - hostname: config.host, - port: config.resolvedPort, + hostname: target.host, + port: target.port, store: hostKeyStore, prompter: prompter ) - try await authenticate(stages, config: config, credentials: credentials) + switch target.role { + case .jumpHost(let key): + try await authenticate(stages, username: target.username, key: key.source, passphrase: key.passphrase) + case .sshServer: + try await authenticate(stages, config: config, credentials: credentials) + } + } - let tunnel = try await stages.beginForwarding( - destination: .tcp(host: remoteHost, port: remotePort) - ) - handedOver = true - return tunnel + // 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. + private static func failing(as target: Target, _ step: () async throws -> Void) async throws { + guard case .jumpHost = target.role else { + try await step() + return + } + do { + try await step() + } catch let error as SSHTunnelError { + switch error { + case .connectionFailed, .handshakeFailed, .authenticationFailed, .noAvailablePort, + .channelOpenFailed, .tunnelClosed: + throw SSHTunnelError.jumpHostFailed(host: target.host, reason: error.localizedDescription) + case .hostKeyRejected, .hostKeyUnverified, .usernameMissing, .jumpHostNeedsKey, + .jumpHostSharedKeyMissing, .jumpHostFailed, .jumpHostKeysUnreadable: + throw error + } + } } private static func authenticate( @@ -66,7 +206,7 @@ nonisolated enum SSHTunnelFactory { config: SSHConfiguration, credentials: SSHTunnelCredentials ) async throws { - switch config.authMethod { + switch config.authMethod.onDevice { case .password: guard let password = credentials.password else { throw SSHTunnelError.authenticationFailed("No SSH password provided") @@ -74,30 +214,44 @@ nonisolated enum SSHTunnelFactory { try await stages.authenticatePassword(username: config.username, password: password) case .privateKey: - switch credentials.privateKeySource(keyPath: config.privateKeyPath) { - case .inMemory(let keyContent): - try await stages.authenticatePublicKeyFromMemory( - username: config.username, - keyContent: keyContent, - passphrase: credentials.keyPassphrase - ) - case .file(let keyPath): - try await stages.authenticatePublicKey( - username: config.username, - keyPath: keyPath, - passphrase: credentials.keyPassphrase - ) - case .missing: - throw SSHTunnelError.authenticationFailed("No private key provided") - } + try await authenticate( + stages, + username: config.username, + key: credentials.privateKeySource(keyPath: config.privateKeyPath), + passphrase: credentials.keyPassphrase + ) case .none: try await stages.authenticateNone(username: config.username) - default: + case .sshAgent, .keyboardInteractive: throw SSHTunnelError.authenticationFailed( "Auth method \(config.authMethod.rawValue) not supported on iOS" ) } } + + private static func authenticate( + _ stages: Stages, + username: String, + key: SSHTunnelCredentials.PrivateKeySource, + passphrase: String? + ) async throws { + switch key { + case .inMemory(let keyContent): + try await stages.authenticatePublicKeyFromMemory( + username: username, + keyContent: keyContent, + passphrase: passphrase + ) + case .file(let keyPath): + try await stages.authenticatePublicKey( + username: username, + keyPath: keyPath, + passphrase: passphrase + ) + case .missing: + throw SSHTunnelError.authenticationFailed("No private key provided") + } + } } diff --git a/apple/TableProMobile/TableProMobile/SSH/SSHTunnelStages.swift b/apple/TableProMobile/TableProMobile/SSH/SSHTunnelStages.swift index e64bb78190..281e231e5a 100644 --- a/apple/TableProMobile/TableProMobile/SSH/SSHTunnelStages.swift +++ b/apple/TableProMobile/TableProMobile/SSH/SSHTunnelStages.swift @@ -4,13 +4,15 @@ import os import TableProCoreTypes import TableProSSHTransport -/// The steps `SSHTunnelFactory` walks to turn a configuration into a forwarding tunnel, named as a -/// protocol so the factory's hand-over guarantee can be tested without an SSH server: a spy that -/// throws at step N asserts the factory released what it had built. +// A protocol so the factory's hand-over guarantee is testable without an SSH server. nonisolated protocol SSHTunnelStages: Sendable { associatedtype Tunnel: Sendable func connect(host: String, port: Int) async throws + + // Opens a channel from the current session to the next host; the next handshake runs over it. + func jump(toHost host: String, port: Int) async throws + func handshake() async throws func hostKey() async throws -> (keyData: Data, keyType: String) func authenticatePassword(username: String, password: String) async throws @@ -24,16 +26,9 @@ nonisolated protocol SSHTunnelStages: Sendable { func discard() } -/// Builds a libssh2 session against an SSH server, one cancellable blocking step at a time. -/// -/// Every step runs on `sessionQueue` through `runCancellableBlocking`, so a cancelled connect -/// returns to the caller at once while the blocking C call finishes on its own thread. The losing -/// attempt never adopts what it produced: `discardLateResult` closes the socket it connected and -/// frees the session it opened, which is what "cancelling a connect does not stop the driver" -/// requires. `libssh2_session_handshake` blocks for its full timeout whatever `Task.cancel()` does. -/// -/// The same queue is handed to the tunnel at the end, so a late connect step and the tunnel's own -/// libssh2 calls can never run at once. +// Every step runs on the current session's queue through `runCancellableBlocking`, so a cancelled +// step returns at once and its late result is freed on that queue. Each session gets its own queue: +// a hop's relay feeds the next handshake, and one shared queue would leave them waiting on each other. nonisolated final class LibSSH2TunnelStages: SSHTunnelStages, @unchecked Sendable { typealias Tunnel = SSHTunnel @@ -54,36 +49,42 @@ nonisolated final class LibSSH2TunnelStages: SSHTunnelStages, @unchecked Sendabl let pointer: OpaquePointer } + private struct ChannelHandle: @unchecked Sendable { + let pointer: OpaquePointer + } + + private struct CurrentSession: Sendable { + let handle: SessionHandle + let queue: DispatchQueue + } + private enum ConnectAttempt { case connected(Int32) case failed(String) } - /// What this builder still owes a release. The listening socket is absent on purpose: nothing - /// between binding it and handing it to the tunnel can throw, so it is never this type's to - /// release. + // The listening socket is absent: nothing between binding it and the hand-over can throw. private struct Resources { + var sessionQueue: DispatchQueue var socketFD: Int32 = -1 var session: SessionHandle? + var hops: [SSHJumpHop] = [] var tunnel: SSHTunnel? } - private let sessionQueue: DispatchQueue - private let resources = OSAllocatedUnfairLock(initialState: Resources()) + private let resources: OSAllocatedUnfairLock private let discarded = TeardownLatch() init() { - sessionQueue = DispatchQueue( - label: "com.TablePro.ssh.session.\(UUID().uuidString)", - qos: .utility - ) + resources = OSAllocatedUnfairLock(initialState: Resources(sessionQueue: Self.makeSessionQueue())) } // MARK: - Steps func connect(host: String, port: Int) async throws { + let queue = resources.withLock { $0.sessionQueue } let fd = try await runCancellableBlocking( - on: sessionQueue, + on: queue, work: { try Self.openSocket(host: host, port: port) }, discardLateResult: { Darwin.close($0) } ) @@ -91,14 +92,56 @@ nonisolated final class LibSSH2TunnelStages: SSHTunnelStages, @unchecked Sendabl Self.logger.debug("TCP connected to \(host, privacy: .private):\(port)") } + func jump(toHost host: String, port: Int) async throws { + let current = try currentSession() + let channel = try await runCancellableBlocking( + on: current.queue, + work: { ChannelHandle(pointer: try Self.openJumpChannel(session: current.handle.pointer, host: host, port: port)) }, + discardLateResult: { libssh2_channel_free($0.pointer) } + ) + + var fds: [Int32] = [-1, -1] + guard socketpair(AF_UNIX, SOCK_STREAM, 0, &fds) == 0 else { + let reason = String(cString: strerror(errno)) + current.queue.async { libssh2_channel_free(channel.pointer) } + throw SSHTunnelError.channelOpenFailed("\(host):\(port): \(reason)") + } + + let relayFD = fds[0] + let nextTransportFD = fds[1] + let transportFD = resources.withLock { $0.socketFD } + let relay = LibSSH2JumpRelay.start( + channel: channel.pointer, + localFD: relayFD, + transportFD: transportFD, + session: current.handle.pointer, + sessionQueue: current.queue + ) + let hop = SSHJumpHop( + session: current.handle.pointer, + socketFD: transportFD, + channel: channel.pointer, + relay: relay, + sessionQueue: current.queue + ) + + resources.withLock { state in + state.hops.append(hop) + state.session = nil + state.socketFD = nextTransportFD + state.sessionQueue = Self.makeSessionQueue() + } + Self.logger.debug("Channel open to \(host, privacy: .private):\(port) through a jump host") + } + func handshake() async throws { - let socketFD = resources.withLock { $0.socketFD } + let (socketFD, queue) = resources.withLock { ($0.socketFD, $0.sessionQueue) } guard socketFD >= 0 else { throw SSHTunnelError.handshakeFailed("No TCP connection") } let handle = try await runCancellableBlocking( - on: sessionQueue, + on: queue, work: { SessionHandle(pointer: try Self.openSession(socketFD: socketFD)) }, discardLateResult: { libssh2_session_free($0.pointer) } ) @@ -106,13 +149,13 @@ nonisolated final class LibSSH2TunnelStages: SSHTunnelStages, @unchecked Sendabl } func hostKey() async throws -> (keyData: Data, keyType: String) { - let handle = try currentSession() + let current = try currentSession() return try await runCancellableBlocking( - on: sessionQueue, + on: current.queue, work: { var keyLength = 0 var keyType: Int32 = 0 - guard let keyPointer = libssh2_session_hostkey(handle.pointer, &keyLength, &keyType) else { + guard let keyPointer = libssh2_session_hostkey(current.handle.pointer, &keyLength, &keyType) else { throw SSHTunnelError.hostKeyRejected("The server did not present a host key.") } return (Data(bytes: keyPointer, count: keyLength), HostKeyStore.keyTypeName(keyType)) @@ -121,10 +164,10 @@ nonisolated final class LibSSH2TunnelStages: SSHTunnelStages, @unchecked Sendabl } func authenticatePassword(username: String, password: String) async throws { - let handle = try currentSession() - try await authenticate(as: username, label: "Password") { + let current = try currentSession() + try await authenticate(as: username, label: "Password", on: current.queue) { libssh2_userauth_password_ex( - handle.pointer, + current.handle.pointer, username, UInt32(username.utf8.count), password, @@ -135,7 +178,7 @@ nonisolated final class LibSSH2TunnelStages: SSHTunnelStages, @unchecked Sendabl } func authenticatePublicKey(username: String, keyPath: String, passphrase: String?) async throws { - let handle = try currentSession() + let current = try currentSession() let expandedPath = (keyPath as NSString).expandingTildeInPath guard FileManager.default.fileExists(atPath: expandedPath) else { @@ -145,9 +188,9 @@ nonisolated final class LibSSH2TunnelStages: SSHTunnelStages, @unchecked Sendabl let publicKeyPath = expandedPath + ".pub" let publicKeyPathOrNil: String? = FileManager.default.fileExists(atPath: publicKeyPath) ? publicKeyPath : nil - try await authenticate(as: username, label: "Public key") { + try await authenticate(as: username, label: "Public key", on: current.queue) { libssh2_userauth_publickey_fromfile_ex( - handle.pointer, + current.handle.pointer, username, UInt32(username.utf8.count), publicKeyPathOrNil, @@ -158,11 +201,11 @@ nonisolated final class LibSSH2TunnelStages: SSHTunnelStages, @unchecked Sendabl } func authenticatePublicKeyFromMemory(username: String, keyContent: String, passphrase: String?) async throws { - let handle = try currentSession() - try await authenticate(as: username, label: "In-memory key") { + let current = try currentSession() + try await authenticate(as: username, label: "In-memory key", on: current.queue) { keyContent.withCString { keyPointer in libssh2_userauth_publickey_frommemory( - handle.pointer, + current.handle.pointer, username, username.utf8.count, nil, 0, @@ -174,16 +217,16 @@ nonisolated final class LibSSH2TunnelStages: SSHTunnelStages, @unchecked Sendabl } func authenticateNone(username: String) async throws { - let handle = try currentSession() + let current = try currentSession() try await runCancellableBlocking( - on: sessionQueue, + on: current.queue, work: { - guard libssh2_userauth_list(handle.pointer, username, UInt32(username.utf8.count)) == nil else { + guard libssh2_userauth_list(current.handle.pointer, username, UInt32(username.utf8.count)) == nil else { throw SSHTunnelError.authenticationFailed( "Server requires credentials; passwordless authentication is not permitted" ) } - guard libssh2_userauth_authenticated(handle.pointer) != 0 else { + guard libssh2_userauth_authenticated(current.handle.pointer) != 0 else { throw SSHTunnelError.authenticationFailed("Passwordless authentication failed") } } @@ -192,21 +235,23 @@ nonisolated final class LibSSH2TunnelStages: SSHTunnelStages, @unchecked Sendabl } func beginForwarding(destination: SSHForwardDestination) async throws -> SSHTunnel { - let handle = try currentSession() - let socketFD = resources.withLock { $0.socketFD } + let current = try currentSession() + let (socketFD, hops) = resources.withLock { ($0.socketFD, $0.hops) } let bound = try Self.bindLocalSocket() let tunnel = SSHTunnel( - session: handle.pointer, + session: current.handle.pointer, socketFD: socketFD, listenFD: bound.fd, localPort: bound.port, - sessionQueue: sessionQueue + sessionQueue: current.queue, + jumpHops: hops ) resources.withLock { state in state.socketFD = -1 state.session = nil + state.hops = [] state.tunnel = tunnel } @@ -222,7 +267,7 @@ nonisolated final class LibSSH2TunnelStages: SSHTunnelStages, @unchecked Sendabl let state = resources.withLock { current -> Resources in let copy = current - current = Resources() + current = Resources(sessionQueue: current.sessionQueue) return copy } @@ -231,36 +276,41 @@ nonisolated final class LibSSH2TunnelStages: SSHTunnelStages, @unchecked Sendabl return } - guard state.socketFD >= 0 || state.session != nil else { return } + guard state.socketFD >= 0 || state.session != nil || !state.hops.isEmpty else { return } if state.socketFD >= 0 { shutdown(state.socketFD, SHUT_RDWR) } - sessionQueue.async { + // Queued behind any late step, so a cancelled handshake or jump has finished first. + state.sessionQueue.async { if let session = state.session?.pointer { libssh2_session_set_blocking(session, 1) tablepro_libssh2_session_disconnect(session, "Closing tunnel") libssh2_session_free(session) } if state.socketFD >= 0 { Darwin.close(state.socketFD) } + guard !state.hops.isEmpty else { return } + Task.detached { await SSHJumpChain.tearDown(state.hops) } } } // MARK: - Private - private func currentSession() throws -> SessionHandle { - guard let handle = resources.withLock({ $0.session }) else { + private func currentSession() throws -> CurrentSession { + let (handle, queue) = resources.withLock { ($0.session, $0.sessionQueue) } + guard let handle else { throw SSHTunnelError.authenticationFailed("No active session") } - return handle + return CurrentSession(handle: handle, queue: queue) } private func authenticate( as username: String, label: String, + on queue: DispatchQueue, _ attempt: @escaping @Sendable () -> Int32 ) async throws { try await runCancellableBlocking( - on: sessionQueue, + on: queue, work: { let rc = attempt() guard rc == 0 else { @@ -271,6 +321,24 @@ nonisolated final class LibSSH2TunnelStages: SSHTunnelStages, @unchecked Sendabl Self.logger.debug("\(label, privacy: .public) authentication successful for \(username, privacy: .private)") } + private static func makeSessionQueue() -> DispatchQueue { + DispatchQueue(label: "com.TablePro.ssh.session.\(UUID().uuidString)", qos: .utility) + } + + private static func openJumpChannel(session: OpaquePointer, host: String, port: Int) throws -> OpaquePointer { + guard let channel = LibSSH2ForwardChannel.open( + session: session, + destination: .tcp(host: host, port: port), + originPort: 0 + ) else { + let reason = LibSSH2ForwardChannel.lastErrorMessage(session: session) + throw SSHTunnelError.channelOpenFailed("\(host):\(port): \(reason)") + } + // The relay reads EAGAIN as "would block", so the hop session runs non-blocking from here. + libssh2_session_set_blocking(session, 0) + return channel + } + private static func openSession(socketFD: Int32) throws -> OpaquePointer { guard let session = tablepro_libssh2_session_init() else { throw SSHTunnelError.handshakeFailed("Failed to initialize libssh2 session") diff --git a/apple/TableProMobile/TableProMobile/Services/IOSImportLibraryStore.swift b/apple/TableProMobile/TableProMobile/Services/IOSImportLibraryStore.swift index 83fb638863..36a16a6a89 100644 --- a/apple/TableProMobile/TableProMobile/Services/IOSImportLibraryStore.swift +++ b/apple/TableProMobile/TableProMobile/Services/IOSImportLibraryStore.swift @@ -112,6 +112,10 @@ struct IOSImportLibraryStore: ImportLibraryStore { var updated = connection updated.sortOrder = stored.sortOrder updated.isFavorite = stored.isFavorite + updated.sshConfiguration = JumpHostCredentialStore.keepingHopIds( + from: stored.sshConfiguration, + into: updated.sshConfiguration + ) stored = updated } if outcome.isSaved { diff --git a/apple/TableProMobile/TableProMobile/ViewModels/ConnectionFormViewModel+Certificates.swift b/apple/TableProMobile/TableProMobile/ViewModels/ConnectionFormViewModel+Certificates.swift index ee2dbbd26e..5a1a92d1a2 100644 --- a/apple/TableProMobile/TableProMobile/ViewModels/ConnectionFormViewModel+Certificates.swift +++ b/apple/TableProMobile/TableProMobile/ViewModels/ConnectionFormViewModel+Certificates.swift @@ -18,6 +18,8 @@ extension ConnectionFormViewModel { for role in CertificateRole.allCases { guard let pem = certificateStore.pem(role: role, for: connectionId) else { continue } storedCertificateRoles.insert(role) + // This runs again when a pushed screen pops, which must not undo a staged or removed certificate. + guard pendingCertificates[role] == nil, !removedCertificates.contains(role) else { continue } certificateSummaries[role] = summary(for: role, pem: pem) } } diff --git a/apple/TableProMobile/TableProMobile/ViewModels/ConnectionFormViewModel+Changes.swift b/apple/TableProMobile/TableProMobile/ViewModels/ConnectionFormViewModel+Changes.swift index c32d9a35c1..55b85fa944 100644 --- a/apple/TableProMobile/TableProMobile/ViewModels/ConnectionFormViewModel+Changes.swift +++ b/apple/TableProMobile/TableProMobile/ViewModels/ConnectionFormViewModel+Changes.swift @@ -6,6 +6,7 @@ nonisolated struct ConnectionFormSecrets: Equatable, Sendable { var sshPassword = "" var sshKeyPassphrase = "" var privateKey: String? + var jumpHostCredentials: [UUID: JumpHostCredential] = [:] } nonisolated struct ConnectionFormSecretWrites: Equatable, Sendable { @@ -68,7 +69,8 @@ extension ConnectionFormViewModel { password: password.isEmpty ? storedSecrets.password : password, sshPassword: sshEnabled && !sshPassword.isEmpty ? sshPassword : storedSecrets.sshPassword, sshKeyPassphrase: sshEnabled && !sshKeyPassphrase.isEmpty ? sshKeyPassphrase : storedSecrets.sshKeyPassphrase, - privateKey: pastedPrivateKey + privateKey: pastedPrivateKey, + jumpHostCredentials: jumpHostCredentialsUnreadable ? storedSecrets.jumpHostCredentials : jumpHostCredentials ) } diff --git a/apple/TableProMobile/TableProMobile/ViewModels/ConnectionFormViewModel+JumpHosts.swift b/apple/TableProMobile/TableProMobile/ViewModels/ConnectionFormViewModel+JumpHosts.swift new file mode 100644 index 0000000000..d9640a565e --- /dev/null +++ b/apple/TableProMobile/TableProMobile/ViewModels/ConnectionFormViewModel+JumpHosts.swift @@ -0,0 +1,112 @@ +import Foundation +import TableProModels + +extension ConnectionFormViewModel { + nonisolated enum JumpHostKeyStatus: Equatable, Sendable { + case sameKey + case ownKey + case noKey + } + + static var jumpHostKeysUnreadableMessage: String { + String(localized: "The jump host keys on this device could not be read. Close the form and open it again to change them.") + } + + var serverSignsInWithKey: Bool { + sshAuthMethod.onDevice == .privateKey + } + + var hasJumpHostWithoutHost: Bool { + sshEnabled && jumpHosts.contains { !$0.hasHost } + } + + var jumpHostCredentials: [UUID: JumpHostCredential] { + guard sshEnabled else { return [:] } + var credentials: [UUID: JumpHostCredential] = [:] + for hop in jumpHosts { + credentials[hop.id] = hop.credential + } + return credentials + } + + // Save rewrites only these hops' entries and keeps those of hops another device added. + var jumpHostIdsShown: Set { + let opening = existingConnection?.sshConfiguration?.jumpHosts.map(\.id) ?? [] + return Set(opening) + .union(jumpHosts.map(\.id)) + .union(storedSecrets.jumpHostCredentials.keys) + } + + func jumpHost(_ id: UUID) -> JumpHostDraft? { + jumpHosts.first { $0.id == id } + } + + func updateJumpHost(_ id: UUID, _ change: (inout JumpHostDraft) -> Void) { + guard let index = jumpHosts.firstIndex(where: { $0.id == id }) else { return } + change(&jumpHosts[index]) + } + + func keyStatus(of hop: JumpHostDraft) -> JumpHostKeyStatus { + if hop.usesServerKey { + return serverSignsInWithKey ? .sameKey : .noKey + } + return hop.keyContent.isEmpty ? .noKey : .ownKey + } + + @discardableResult + func addJumpHost() -> UUID { + let hop = JumpHostDraft() + jumpHosts.append(hop) + return hop.id + } + + func removeJumpHosts(atOffsets offsets: IndexSet) { + var remaining = jumpHosts + for index in offsets.sorted(by: >) where remaining.indices.contains(index) { + remaining.remove(at: index) + } + jumpHosts = remaining + } + + func moveJumpHosts(fromOffsets source: IndexSet, toOffset destination: Int) { + let valid = source.filter { jumpHosts.indices.contains($0) } + let moving = valid.map { jumpHosts[$0] } + var remaining = jumpHosts + for index in valid.sorted(by: >) { + remaining.remove(at: index) + } + let insertion = min(destination - valid.filter { $0 < destination }.count, remaining.count) + remaining.insert(contentsOf: moving, at: max(insertion, 0)) + jumpHosts = remaining + } + + @discardableResult + func importJumpHostKey(_ result: Result<[URL], any Error>, for id: UUID) -> Bool { + let url: URL + switch result { + case .success(let urls): + guard let first = urls.first else { return false } + url = first + case .failure(let error): + jumpHostKeyFileError = error.localizedDescription + return false + } + let didStart = url.startAccessingSecurityScopedResource() + defer { + if didStart { url.stopAccessingSecurityScopedResource() } + } + guard let content = try? String(contentsOf: url, encoding: .utf8) else { + jumpHostKeyFileError = String(localized: "That file is not a text private key.") + return false + } + updateJumpHost(id) { hop in + hop.usesServerKey = false + hop.keyContent = content + } + return true + } + + func dismissJumpHostKeyFileError() { + jumpHostKeyFileError = nil + } +} diff --git a/apple/TableProMobile/TableProMobile/ViewModels/ConnectionFormViewModel.swift b/apple/TableProMobile/TableProMobile/ViewModels/ConnectionFormViewModel.swift index 9b07a45b2a..3df049d7e9 100644 --- a/apple/TableProMobile/TableProMobile/ViewModels/ConnectionFormViewModel.swift +++ b/apple/TableProMobile/TableProMobile/ViewModels/ConnectionFormViewModel.swift @@ -84,6 +84,11 @@ final class ConnectionFormViewModel { var sshKeyPassphrase = "" var sshKeyInputMode: KeyInputMode = .file + // Jump hosts + var jumpHosts: [JumpHostDraft] = [] + var jumpHostKeyFileError: String? + private(set) var jumpHostCredentialsUnreadable = false + // File picker output var selectedFileURL: URL? var newDatabaseName = "" @@ -108,6 +113,7 @@ final class ConnectionFormViewModel { @ObservationIgnored let connectionId: UUID @ObservationIgnored private var createdFileURL: URL? @ObservationIgnored private var addedNewConnection = false + @ObservationIgnored private var loadedStoredCredentials = false private(set) var baseline: ConnectionFormSnapshot? private(set) var storedSecrets = ConnectionFormSecrets() private let localFiles: LocalDatabaseFileLocator @@ -159,11 +165,12 @@ final class ConnectionFormViewModel { sshHost = ssh.host sshPort = ssh.port.map(String.init) ?? "" sshUsername = ssh.username - sshAuthMethod = ssh.authMethod + sshAuthMethod = ssh.authMethod.onDevice sshKeyPath = ssh.privateKeyPath ?? "" - if ssh.authMethod == .privateKey, sshKeyPath.isEmpty { + if sshAuthMethod == .privateKey, sshKeyPath.isEmpty { sshKeyInputMode = .paste } + jumpHosts = ssh.jumpHosts.map(JumpHostDraft.init(hop:)) } hydrateDatabaseFile(from: conn) } @@ -190,7 +197,7 @@ final class ConnectionFormViewModel { if type == .duckdb { return duckDBInMemory || !database.isEmpty } - return !host.isEmpty + return !host.isEmpty && !hasJumpHostWithoutHost } var isFileBased: Bool { @@ -200,11 +207,22 @@ final class ConnectionFormViewModel { var isEditing: Bool { existingConnection != nil } var pastedPrivateKey: String? { - guard sshEnabled, sshAuthMethod == .privateKey, sshKeyInputMode == .paste, + guard sshEnabled, sshAuthMethod.onDevice == .privateKey, sshKeyInputMode == .paste, !sshKeyContent.isEmpty else { return nil } return sshKeyContent } + var sshAuthMethodNote: String? { + switch sshAuthMethod { + case .keyboardInteractive: + String(localized: "Keyboard Interactive is set on the Mac and is not available on iPhone and iPad.") + case .privateKey where existingConnection?.sshConfiguration?.authMethod == .sshAgent: + String(localized: "Set to SSH Agent on the Mac. iPhone and iPad sign in with a private key.") + default: + nil + } + } + var edits: ConnectionFormEdits { ConnectionFormEdits( name: name.isEmpty ? (selectedFileURL?.lastPathComponent ?? host) : name, @@ -247,11 +265,21 @@ final class ConnectionFormViewModel { host: sshHost, port: Int(sshPort), username: sshUsername, - authMethod: sshAuthMethod, - privateKeyPath: sshKeyPath.isEmpty ? nil : sshKeyPath + authMethod: sshAuthMethodToStore, + privateKeyPath: sshKeyPath.isEmpty ? nil : sshKeyPath, + jumpHosts: jumpHosts.map(\.edit) ) } + // The picker shows how this device signs in. Left as it opened, the method goes back as the Mac + // stored it, which matters when a switched-off tunnel is turned on and every field is written. + private var sshAuthMethodToStore: SSHConfiguration.SSHAuthMethod { + guard let stored = existingConnection?.sshConfiguration?.authMethod, stored.onDevice == sshAuthMethod else { + return sshAuthMethod + } + return stored + } + private var oracleOptions: ConnectionFormEdits.OracleOptions? { guard type == .oracle else { return nil } return ConnectionFormEdits.OracleOptions( @@ -265,8 +293,10 @@ final class ConnectionFormViewModel { // MARK: - Credential Hydration + // The form's `.task` runs again whenever a pushed screen pops, and a second load would overwrite edits. func loadStoredCredentials(secureStore: any SecureStore) async { - guard let conn = existingConnection else { return } + guard let conn = existingConnection, !loadedStoredCredentials else { return } + loadedStoredCredentials = true if let stored = Self.storedSecret(.password, for: conn.id, in: secureStore) { password = stored storedSecrets.password = stored @@ -284,9 +314,29 @@ final class ConnectionFormViewModel { storedSecrets.privateKey = privateKey sshKeyInputMode = .paste } + loadJumpHostCredentials(for: conn, in: secureStore) baseline?.secrets = snapshot.secrets } + private func loadJumpHostCredentials(for connection: DatabaseConnection, in secureStore: any SecureStore) { + guard connection.sshConfiguration?.jumpHosts.isEmpty == false else { return } + let loaded: [UUID: JumpHostCredential] + do { + loaded = try JumpHostCredentialStore.load(for: connection.id, in: secureStore) + } catch { + Self.logger.error("Failed to read jump host keys: \(error.localizedDescription, privacy: .private)") + jumpHostCredentialsUnreadable = true + credentialError = Self.jumpHostKeysUnreadableMessage + return + } + let shown = jumpHostIdsShown + storedSecrets.jumpHostCredentials = loaded.filter { shown.contains($0.key) } + for index in jumpHosts.indices { + guard let credential = loaded[jumpHosts[index].id] else { continue } + jumpHosts[index].adopt(credential) + } + } + private static func storedSecret( _ kind: ConnectionSecretKind, for connectionId: UUID, @@ -451,6 +501,14 @@ final class ConnectionFormViewModel { if let pastedPrivateKey { secrets[ConnectionSecretKind.sshPrivateKey.account(for: connectionId)] = pastedPrivateKey } + let hopCredentials = jumpHostCredentials + guard !hopCredentials.isEmpty else { return secrets } + do { + secrets[ConnectionSecretKind.jumpHostKeys.account(for: connectionId)] = + try JumpHostCredentialStore.encode(hopCredentials) + } catch { + Self.logger.error("Failed to encode jump host keys: \(error.localizedDescription, privacy: .private)") + } return secrets } @@ -666,6 +724,13 @@ final class ConnectionFormViewModel { storageFailed = true } + do { + try persistJumpHostCredentials(secureStore: secureStore) + } catch { + Self.logger.error("Failed to store jump host keys: \(error.localizedDescription, privacy: .private)") + storageFailed = true + } + guard !storageFailed else { credentialError = String(localized: "Some credentials could not be saved to the keychain. You may need to re-enter them later.") return false @@ -694,6 +759,23 @@ final class ConnectionFormViewModel { storedSecrets.privateKey = key } + // The item is synced to every device, which may have changed it while the form was open, so it + // is re-read and only the entries this form changed are written. An item that could not be read + // when the form opened is never written; a failed re-read throws, and the next Save tries again. + func persistJumpHostCredentials(secureStore: any SecureStore) throws { + guard !jumpHostCredentialsUnreadable else { return } + let credentials = jumpHostCredentials + let loaded = storedSecrets.jumpHostCredentials + guard credentials != loaded else { return } + var stored = try JumpHostCredentialStore.load(for: connectionId, in: secureStore) + let kept = Set(jumpHosts.map(\.id)) + for hopId in jumpHostIdsShown where !kept.contains(hopId) || credentials[hopId] != loaded[hopId] { + stored[hopId] = credentials[hopId] + } + try JumpHostCredentialStore.save(sshEnabled ? stored : [:], for: connectionId, in: secureStore) + storedSecrets.jumpHostCredentials = credentials + } + func dismissCredentialError() { credentialError = nil } diff --git a/apple/TableProMobile/TableProMobile/ViewModels/JumpHostDraft.swift b/apple/TableProMobile/TableProMobile/ViewModels/JumpHostDraft.swift new file mode 100644 index 0000000000..6dd2882c8c --- /dev/null +++ b/apple/TableProMobile/TableProMobile/ViewModels/JumpHostDraft.swift @@ -0,0 +1,48 @@ +import Foundation +import TableProModels + +nonisolated struct JumpHostDraft: Identifiable, Equatable, Sendable { + let id: UUID + var host: String + var port: String + var username: String + var usesServerKey = false + var keyContent = "" + var keyPassphrase = "" + + init(id: UUID = UUID(), host: String = "", port: String = "", username: String = "") { + self.id = id + self.host = host + self.port = port + self.username = username + } + + init(hop: SSHJumpHost) { + self.init(id: hop.id, host: hop.host, port: hop.port.map(String.init) ?? "", username: hop.username) + } + + var edit: ConnectionFormEdits.JumpHostEdit { + ConnectionFormEdits.JumpHostEdit(id: id, host: host, port: Int(port), username: username) + } + + var credential: JumpHostCredential? { + if usesServerKey { return .sshServerKey } + guard !keyContent.isEmpty else { return nil } + return .privateKey(key: keyContent, passphrase: keyPassphrase.isEmpty ? nil : keyPassphrase) + } + + var hasHost: Bool { + !host.trimmingCharacters(in: .whitespaces).isEmpty + } + + mutating func adopt(_ credential: JumpHostCredential) { + switch credential { + case .sshServerKey: + usesServerKey = true + case .privateKey(let key, let passphrase): + usesServerKey = false + keyContent = key + keyPassphrase = passphrase ?? "" + } + } +} diff --git a/apple/TableProMobile/TableProMobile/Views/Components/JumpHostEditorView.swift b/apple/TableProMobile/TableProMobile/Views/Components/JumpHostEditorView.swift new file mode 100644 index 0000000000..b7287857c6 --- /dev/null +++ b/apple/TableProMobile/TableProMobile/Views/Components/JumpHostEditorView.swift @@ -0,0 +1,132 @@ +import SwiftUI +import UniformTypeIdentifiers + +struct JumpHostEditorView: View { + let viewModel: ConnectionFormViewModel + let hopId: UUID + + @State private var keyInputMode: ConnectionFormViewModel.KeyInputMode + @State private var showKeyImporter = false + + init(viewModel: ConnectionFormViewModel, hopId: UUID) { + self.viewModel = viewModel + self.hopId = hopId + let hasKey = viewModel.jumpHost(hopId)?.keyContent.isEmpty == false + _keyInputMode = State(initialValue: hasKey ? .paste : .file) + } + + private var hop: JumpHostDraft? { + viewModel.jumpHost(hopId) + } + + private var draft: Binding { + Binding( + get: { viewModel.jumpHost(hopId) ?? JumpHostDraft(id: hopId) }, + set: { value in viewModel.updateJumpHost(hopId) { $0 = value } } + ) + } + + private var showKeyFileError: Binding { + Binding( + get: { viewModel.jumpHostKeyFileError != nil }, + set: { if !$0 { viewModel.dismissJumpHostKeyFileError() } } + ) + } + + private var title: String { + guard let hop, hop.hasHost else { return String(localized: "New Jump Host") } + return hop.host + } + + var body: some View { + Form { + Section { + TextField("Host", text: draft.host) + .textInputAutocapitalization(.never) + .autocorrectionDisabled() + .keyboardType(.URL) + TextField("Port", text: draft.port, prompt: Text(verbatim: "22")) + .keyboardType(.numberPad) + TextField("Username", text: draft.username) + .textInputAutocapitalization(.never) + .autocorrectionDisabled() + .keyboardType(.asciiCapable) + } + + keySection + } + .navigationTitle(title) + .navigationBarTitleDisplayMode(.inline) + .fileImporter(isPresented: $showKeyImporter, allowedContentTypes: [.data], allowsMultipleSelection: false) { result in + if viewModel.importJumpHostKey(result, for: hopId) { + keyInputMode = .paste + } + } + .alert("Private Key", isPresented: showKeyFileError) { + Button("OK", role: .cancel) {} + } message: { + Text(viewModel.jumpHostKeyFileError ?? "") + } + } + + @ViewBuilder + private var keySection: some View { + let usesServerKey = hop?.usesServerKey ?? false + Section { + Toggle("Same Key as SSH Server", isOn: draft.usesServerKey) + .disabled(!viewModel.serverSignsInWithKey && !usesServerKey) + + if !usesServerKey { + Picker("Input Method", selection: $keyInputMode) { + ForEach(ConnectionFormViewModel.KeyInputMode.allCases, id: \.self) { mode in + Text(mode.displayName).tag(mode) + } + } + .pickerStyle(.segmented) + + if keyInputMode == .file { + Button { + showKeyImporter = true + } label: { + HStack { + Text("Select Private Key") + Spacer() + Image(systemName: "folder") + } + } + } else { + keyEditor + } + + SecureField("Passphrase (optional)", text: draft.keyPassphrase) + } + } header: { + Text("Private Key") + } footer: { + if viewModel.jumpHostCredentialsUnreadable { + Text(ConnectionFormViewModel.jumpHostKeysUnreadableMessage) + } else if !viewModel.serverSignsInWithKey { + Text("The SSH server does not sign in with a private key, so this jump host needs its own key.") + } + } + .disabled(viewModel.jumpHostCredentialsUnreadable) + } + + private var keyEditor: some View { + TextEditor(text: draft.keyContent) + .font(.system(.caption, design: .monospaced)) + .frame(minHeight: 120) + .autocorrectionDisabled() + .textInputAutocapitalization(.never) + .overlay(alignment: .topLeading) { + if hop?.keyContent.isEmpty ?? true { + Text("Paste private key (PEM format)") + .font(.system(.caption, design: .monospaced)) + .foregroundStyle(.tertiary) + .padding(.top, 8) + .padding(.leading, 4) + .allowsHitTesting(false) + } + } + } +} diff --git a/apple/TableProMobile/TableProMobile/Views/Components/JumpHostsSection.swift b/apple/TableProMobile/TableProMobile/Views/Components/JumpHostsSection.swift new file mode 100644 index 0000000000..6c3afa2a2a --- /dev/null +++ b/apple/TableProMobile/TableProMobile/Views/Components/JumpHostsSection.swift @@ -0,0 +1,60 @@ +import SwiftUI + +struct JumpHostsSection: View { + let viewModel: ConnectionFormViewModel + var onAdd: (UUID) -> Void + + var body: some View { + Section { + ForEach(viewModel.jumpHosts) { hop in + NavigationLink { + JumpHostEditorView(viewModel: viewModel, hopId: hop.id) + } label: { + row(for: hop) + } + } + .onDelete { viewModel.removeJumpHosts(atOffsets: $0) } + .onMove { viewModel.moveJumpHosts(fromOffsets: $0, toOffset: $1) } + + Button { + onAdd(viewModel.addJumpHost()) + } label: { + Label("Add Jump Host", systemImage: "plus.circle") + } + } header: { + Text("Jump Hosts") + } footer: { + Text("Jump hosts are connected in order before reaching the SSH server above.") + } + } + + private func row(for hop: JumpHostDraft) -> some View { + VStack(alignment: .leading, spacing: 2) { + if hop.hasHost { + Text(verbatim: hop.username.isEmpty ? hop.host : "\(hop.username)@\(hop.host)") + } else { + Text("New Jump Host") + .foregroundStyle(.secondary) + } + if !viewModel.jumpHostCredentialsUnreadable { + keyStatus(viewModel.keyStatus(of: hop)) + .font(.caption) + } + } + } + + @ViewBuilder + private func keyStatus(_ status: ConnectionFormViewModel.JumpHostKeyStatus) -> some View { + switch status { + case .sameKey: + Text("Same key as SSH server") + .foregroundStyle(.secondary) + case .ownKey: + Text("Own key") + .foregroundStyle(.secondary) + case .noKey: + Text("No key on this device") + .foregroundStyle(.red) + } + } +} diff --git a/apple/TableProMobile/TableProMobile/Views/ConnectionFormView.swift b/apple/TableProMobile/TableProMobile/Views/ConnectionFormView.swift index 1566375b57..85f3a15a6b 100644 --- a/apple/TableProMobile/TableProMobile/Views/ConnectionFormView.swift +++ b/apple/TableProMobile/TableProMobile/Views/ConnectionFormView.swift @@ -18,6 +18,7 @@ struct ConnectionFormView: View { @State private var hapticError = false @State private var pasteTarget: CertificateRole? @State private var showPKCS12Password = false + @State private var pushedJumpHostId: UUID? var onSaved: (UUID) -> Void @@ -168,6 +169,9 @@ struct ConnectionFormView: View { case nil: break } } + .navigationDestination(item: $pushedJumpHostId) { hopId in + JumpHostEditorView(viewModel: viewModel, hopId: hopId) + } .connectionPrompts(viewModel.prompts) .onDisappear { viewModel.prompts.cancelAll() } .sheet(item: $pasteTarget) { role in @@ -433,7 +437,7 @@ struct ConnectionFormView: View { } if viewModel.sshEnabled { - Section("SSH Server") { + Section { TextField("SSH Host", text: $viewModel.sshHost) .textInputAutocapitalization(.never) .keyboardType(.URL) @@ -447,15 +451,23 @@ struct ConnectionFormView: View { Text("Private Key").tag(SSHConfiguration.SSHAuthMethod.privateKey) } .pickerStyle(.segmented) + } header: { + Text("SSH Server") + } footer: { + if let note = viewModel.sshAuthMethodNote { + Text(note) + } } if viewModel.sshAuthMethod == .password { Section("SSH Password") { SecureField("Password", text: $viewModel.sshPassword) } - } else { + } else if viewModel.sshAuthMethod == .privateKey { privateKeySection(viewModel: viewModel) } + + JumpHostsSection(viewModel: viewModel) { pushedJumpHostId = $0 } } } diff --git a/apple/TableProMobile/TableProMobile/Views/ConnectionInfoView.swift b/apple/TableProMobile/TableProMobile/Views/ConnectionInfoView.swift index 24e20baa81..eff65c41fd 100644 --- a/apple/TableProMobile/TableProMobile/Views/ConnectionInfoView.swift +++ b/apple/TableProMobile/TableProMobile/Views/ConnectionInfoView.swift @@ -102,12 +102,16 @@ struct ConnectionInfoView: View { .textSelection(.enabled) } LabeledContent("SSH Username", value: ssh.username) - LabeledContent( - "Auth", - value: ssh.authMethod == .password - ? String(localized: "Password") - : String(localized: "Private Key") - ) + LabeledContent("Auth", value: authLabel(ssh.authMethod.onDevice)) + } + } + + private func authLabel(_ method: SSHConfiguration.SSHAuthMethod) -> String { + switch method { + case .password: String(localized: "Password") + case .privateKey, .sshAgent: String(localized: "Private Key") + case .keyboardInteractive: String(localized: "Keyboard Interactive") + case .none: String(localized: "None") } } diff --git a/apple/TableProMobile/TableProMobileTests/ConnectionErrorClassifierTests.swift b/apple/TableProMobile/TableProMobileTests/ConnectionErrorClassifierTests.swift index 73a8293aa0..98ecc130b5 100644 --- a/apple/TableProMobile/TableProMobileTests/ConnectionErrorClassifierTests.swift +++ b/apple/TableProMobile/TableProMobileTests/ConnectionErrorClassifierTests.swift @@ -66,4 +66,52 @@ struct ConnectionErrorClassifierTests { #expect(error.title == String(localized: "Not Connected")) #expect(error.recovery == String(localized: "Reconnect and try again.")) } + + @Test("A jump host or username failure is an SSH failure by its case, with a way to fix it", arguments: [ + ( + SSHTunnelError.usernameMissing(host: "bastion.internal"), + String(localized: "Edit the connection and enter the username under SSH Server or Jump Hosts.") + ), + ( + SSHTunnelError.jumpHostNeedsKey(host: "bastion.internal"), + String(localized: "Edit the connection and set a key for this jump host under Jump Hosts.") + ), + ( + SSHTunnelError.jumpHostSharedKeyMissing(host: "bastion.internal"), + String(localized: "Edit the connection and set a key for this jump host under Jump Hosts.") + ), + ( + SSHTunnelError.jumpHostFailed(host: "bastion.internal", reason: "Connection timed out"), + String(localized: "Check this jump host's address, port, username and key under Jump Hosts.") + ), + ( + SSHTunnelError.jumpHostKeysUnreadable("Keychain retrieve failed: -25300"), + String(localized: "Try again. If it keeps failing, update TablePro on this device.") + ) + ]) + func jumpHostFailuresAreSSH(error: SSHTunnelError, recovery: String) { + let classified = ErrorClassifier.classify( + error, + context: ErrorContext(operation: "connect", host: "db.internal", sshEnabled: true) + ) + + #expect(classified.category == .ssh) + #expect(classified.title == String(localized: "SSH Tunnel Failed")) + #expect(classified.message == error.localizedDescription) + #expect(classified.recovery == recovery) + } + + @Test("A jump host error names the host it is about") + func jumpHostErrorsNameTheHost() { + let errors: [SSHTunnelError] = [ + .usernameMissing(host: "bastion.internal"), + .jumpHostNeedsKey(host: "bastion.internal"), + .jumpHostSharedKeyMissing(host: "bastion.internal"), + .jumpHostFailed(host: "bastion.internal", reason: "Connection timed out") + ] + + for error in errors { + #expect(error.localizedDescription.contains("bastion.internal")) + } + } } diff --git a/apple/TableProMobile/TableProMobileTests/ConnectionFormViewModelJumpHostTests.swift b/apple/TableProMobile/TableProMobileTests/ConnectionFormViewModelJumpHostTests.swift new file mode 100644 index 0000000000..24e42e2e54 --- /dev/null +++ b/apple/TableProMobile/TableProMobileTests/ConnectionFormViewModelJumpHostTests.swift @@ -0,0 +1,410 @@ +import Foundation +import TableProDatabase +import TableProModels +import Testing + +@testable import TableProMobile + +private struct KeychainLocked: Error {} + +private final class RecordingSecureStore: SecureStore, @unchecked Sendable { + private var storage: [String: String] = [:] + var unreadableAccounts: Set = [] + private(set) var changedAccounts: [String] = [] + + func store(_ value: String, forKey key: String) throws { + changedAccounts.append(key) + storage[key] = value + } + + func retrieve(forKey key: String) throws -> String? { + guard !unreadableAccounts.contains(key) else { throw KeychainLocked() } + return storage[key] + } + + func delete(forKey key: String) throws { + changedAccounts.append(key) + storage.removeValue(forKey: key) + } + + func seed(_ key: String, _ value: String) { + storage[key] = value + } + + func peek(_ key: String) -> String? { + storage[key] + } +} + +@MainActor +struct ConnectionFormViewModelJumpHostTests { + private let firstHop = SSHJumpHost(host: "bastion1.example.com", username: "ops") + private let secondHop = SSHJumpHost(host: "bastion2.internal", port: 2_222, username: "ops") + private let hopKey = "-----BEGIN OPENSSH PRIVATE KEY-----\nhop\n-----END OPENSSH PRIVATE KEY-----\n" + private let otherKey = "-----BEGIN OPENSSH PRIVATE KEY-----\nother\n-----END OPENSSH PRIVATE KEY-----\n" + + private func chained(authMethod: SSHConfiguration.SSHAuthMethod = .privateKey) -> DatabaseConnection { + DatabaseConnection( + name: "Chain", + type: .postgresql, + host: "10.0.0.5", + port: 5_432, + sshEnabled: true, + sshConfiguration: SSHConfiguration( + host: "db-ssh.internal", + username: "deploy", + authMethod: authMethod, + jumpHosts: [firstHop, secondHop] + ) + ) + } + + private func account(_ connectionId: UUID) -> String { + "com.TablePro.jumphostkeys.\(connectionId.uuidString)" + } + + private func seed(_ credentials: [UUID: JumpHostCredential], for connectionId: UUID, in store: RecordingSecureStore) throws { + let encoded = try JumpHostCredentialStore.encode(credentials) + store.seed(account(connectionId), encoded) + } + + private func stored(for connectionId: UUID, in store: RecordingSecureStore) throws -> [UUID: JumpHostCredential] { + try JumpHostCredentialStore.load(for: connectionId, in: store) + } + + private func loadedForm(_ connection: DatabaseConnection, store: RecordingSecureStore) async -> ConnectionFormViewModel { + let form = ConnectionFormViewModel(editing: connection) + await form.loadStoredCredentials(secureStore: store) + return form + } + + @Test("Editing a connection shows its hops and the key this device holds for each") + func hydratesHopsAndKeys() async throws { + let connection = chained() + let store = RecordingSecureStore() + try seed( + [firstHop.id: .sshServerKey, secondHop.id: .privateKey(key: hopKey, passphrase: "unlock")], + for: connection.id, + in: store + ) + + let form = await loadedForm(connection, store: store) + + let hops = form.jumpHosts + #expect(hops.count == 2) + #expect(hops[0].id == firstHop.id) + #expect(hops[0].host == "bastion1.example.com") + #expect(hops[0].port.isEmpty) + #expect(hops[0].usesServerKey) + #expect(hops[1].port == "2222") + #expect(hops[1].keyContent == hopKey) + #expect(hops[1].keyPassphrase == "unlock") + #expect(form.keyStatus(of: hops[0]) == .sameKey) + #expect(form.keyStatus(of: hops[1]) == .ownKey) + #expect(form.hasChanges == false) + #expect(form.jumpHostCredentialsUnreadable == false) + } + + @Test("Loading the stored keys again, as returning from a hop's screen does, keeps the form's edits") + func secondLoadKeepsEdits() async throws { + let connection = chained() + let store = RecordingSecureStore() + try seed([secondHop.id: .privateKey(key: hopKey, passphrase: nil)], for: connection.id, in: store) + let form = await loadedForm(connection, store: store) + + form.updateJumpHost(secondHop.id) { $0.keyContent = otherKey } + await form.loadStoredCredentials(secureStore: store) + + #expect(form.jumpHost(secondHop.id)?.keyContent == otherKey) + #expect(form.hasChanges) + } + + @Test("A hop with no key here, or on Same Key over a password tunnel, has no key on this device") + func missingKeysAreReported() async throws { + let connection = chained(authMethod: .password) + let store = RecordingSecureStore() + try seed([firstHop.id: .sshServerKey], for: connection.id, in: store) + + let form = await loadedForm(connection, store: store) + + let hops = form.jumpHosts + #expect(form.keyStatus(of: hops[0]) == .noKey) + #expect(form.keyStatus(of: hops[1]) == .noKey) + } + + @Test("Keys set for hops are saved in the connection's jump host item") + func savesHopKeys() async throws { + let connection = chained() + let store = RecordingSecureStore() + let form = await loadedForm(connection, store: store) + + form.updateJumpHost(firstHop.id) { $0.usesServerKey = true } + form.updateJumpHost(secondHop.id) { + $0.keyContent = hopKey + $0.keyPassphrase = "unlock" + } + try form.persistJumpHostCredentials(secureStore: store) + + let expected: [UUID: JumpHostCredential] = [ + firstHop.id: .sshServerKey, + secondHop.id: .privateKey(key: hopKey, passphrase: "unlock") + ] + #expect(try stored(for: connection.id, in: store) == expected) + } + + @Test("Removing a hop drops its key, and removing the last keyed hop deletes the item") + func removingHopsDropsTheirKeys() async throws { + let connection = chained() + let store = RecordingSecureStore() + try seed( + [firstHop.id: .sshServerKey, secondHop.id: .privateKey(key: hopKey, passphrase: nil)], + for: connection.id, + in: store + ) + let form = await loadedForm(connection, store: store) + + form.removeJumpHosts(atOffsets: IndexSet(integer: 0)) + try form.persistJumpHostCredentials(secureStore: store) + let remaining: [UUID: JumpHostCredential] = [secondHop.id: .privateKey(key: hopKey, passphrase: nil)] + #expect(try stored(for: connection.id, in: store) == remaining) + + form.removeJumpHosts(atOffsets: IndexSet(integer: 0)) + try form.persistJumpHostCredentials(secureStore: store) + #expect(store.peek(account(connection.id)) == nil) + } + + @Test("Turning SSH off deletes the jump host item") + func sshOffDeletesTheItem() async throws { + let connection = chained() + let store = RecordingSecureStore() + try seed([firstHop.id: .sshServerKey], for: connection.id, in: store) + let form = await loadedForm(connection, store: store) + + form.sshEnabled = false + try form.persistJumpHostCredentials(secureStore: store) + + #expect(store.peek(account(connection.id)) == nil) + } + + @Test("Saving unchanged hop keys writes nothing") + func unchangedKeysAreNotRewritten() async throws { + let connection = chained() + let store = RecordingSecureStore() + try seed([firstHop.id: .sshServerKey], for: connection.id, in: store) + let form = await loadedForm(connection, store: store) + + form.name = "Renamed" + form.moveJumpHosts(fromOffsets: IndexSet(integer: 1), toOffset: 0) + try form.persistJumpHostCredentials(secureStore: store) + + #expect(store.changedAccounts.isEmpty) + } + + @Test("A key another device stored for a hop this form does not show survives Save") + func unshownHopKeySurvives() async throws { + let connection = chained() + let store = RecordingSecureStore() + let elsewhere = UUID() + try seed( + [firstHop.id: .sshServerKey, elsewhere: .privateKey(key: otherKey, passphrase: nil)], + for: connection.id, + in: store + ) + let form = await loadedForm(connection, store: store) + let later = UUID() + try seed( + [ + firstHop.id: .sshServerKey, + elsewhere: .privateKey(key: otherKey, passphrase: nil), + later: .sshServerKey + ], + for: connection.id, + in: store + ) + + let loadedIds = Set(form.storedSecrets.jumpHostCredentials.keys) + #expect(loadedIds == Set([firstHop.id])) + + form.updateJumpHost(secondHop.id) { $0.keyContent = hopKey } + try form.persistJumpHostCredentials(secureStore: store) + + let saved = try stored(for: connection.id, in: store) + #expect(saved[elsewhere] == .privateKey(key: otherKey, passphrase: nil)) + #expect(saved[later] == .sshServerKey) + #expect(saved[firstHop.id] == .sshServerKey) + #expect(saved[secondHop.id] == .privateKey(key: hopKey, passphrase: nil)) + } + + @Test( + "A key another device set for a hop this form left alone survives Save of another hop's key", + arguments: [true, false] + ) + func untouchedHopKeyFromElsewhereSurvives(hadKeyWhenOpened: Bool) async throws { + let connection = chained() + let store = RecordingSecureStore() + if hadKeyWhenOpened { + try seed([firstHop.id: .sshServerKey], for: connection.id, in: store) + } + let form = await loadedForm(connection, store: store) + try seed([firstHop.id: .privateKey(key: otherKey, passphrase: nil)], for: connection.id, in: store) + + form.updateJumpHost(secondHop.id) { $0.keyContent = hopKey } + try form.persistJumpHostCredentials(secureStore: store) + + let expected: [UUID: JumpHostCredential] = [ + firstHop.id: .privateKey(key: otherKey, passphrase: nil), + secondHop.id: .privateKey(key: hopKey, passphrase: nil) + ] + #expect(try stored(for: connection.id, in: store) == expected) + } + + @Test("An item that cannot be read turns off hop key edits, and Save after an edit leaves it untouched") + func unreadableItemIsNeverWritten() async throws { + let connection = chained() + let store = RecordingSecureStore() + try seed([firstHop.id: .sshServerKey], for: connection.id, in: store) + let original = store.peek(account(connection.id)) + store.unreadableAccounts = [account(connection.id)] + let fixture = try AppStateFixture() + let appState = fixture.makeState(syncEnabled: false) + #expect(appState.addConnection(connection)) + + let form = await loadedForm(connection, store: store) + #expect(form.jumpHostCredentialsUnreadable) + #expect(form.credentialError != nil) + #expect(form.hasChanges == false) + + form.dismissCredentialError() + form.name = "Renamed" + form.updateJumpHost(secondHop.id) { $0.keyContent = hopKey } + _ = await form.save(appState: appState, secureStore: store) + + #expect(!store.changedAccounts.contains(account(connection.id))) + #expect(store.peek(account(connection.id)) == original) + } + + @Test("An item that cannot be read again at Save is left as it is, and the next Save stores the edit") + func failedReReadKeepsTheItemAndRetries() async throws { + let connection = chained() + let store = RecordingSecureStore() + try seed([firstHop.id: .sshServerKey], for: connection.id, in: store) + let original = store.peek(account(connection.id)) + let form = await loadedForm(connection, store: store) + store.unreadableAccounts = [account(connection.id)] + + form.updateJumpHost(secondHop.id) { $0.keyContent = hopKey } + #expect(throws: (any Error).self) { + try form.persistJumpHostCredentials(secureStore: store) + } + + #expect(store.changedAccounts.isEmpty) + #expect(store.peek(account(connection.id)) == original) + #expect(form.hasChanges) + + store.unreadableAccounts = [] + try form.persistJumpHostCredentials(secureStore: store) + + let expected: [UUID: JumpHostCredential] = [ + firstHop.id: .sshServerKey, + secondHop.id: .privateKey(key: hopKey, passphrase: nil) + ] + #expect(try stored(for: connection.id, in: store) == expected) + } + + @Test("Test Connection hands the tunnel the hop keys under the throwaway id only") + func testSecretsCarryHopKeys() async throws { + let connection = chained() + let store = RecordingSecureStore() + try seed([firstHop.id: .sshServerKey], for: connection.id, in: store) + let form = await loadedForm(connection, store: store) + form.updateJumpHost(secondHop.id) { $0.keyContent = hopKey } + let tempId = UUID() + + let secrets = form.testSecrets(for: tempId) + let handed = try JumpHostCredentialStore.load(for: tempId, in: EphemeralSecureStore(secrets)) + + let expected: [UUID: JumpHostCredential] = [ + firstHop.id: .sshServerKey, + secondHop.id: .privateKey(key: hopKey, passphrase: nil) + ] + #expect(handed == expected) + #expect(secrets.keys.allSatisfy { $0.hasSuffix(tempId.uuidString) }) + + form.sshEnabled = false + #expect(form.testSecrets(for: tempId)[account(tempId)] == nil) + } + + @Test("A hop key edit is a change that reconnects after Save, and putting it back is not") + func hopKeyEditsAreChanges() async throws { + let connection = chained() + let store = RecordingSecureStore() + try seed([secondHop.id: .privateKey(key: hopKey, passphrase: nil)], for: connection.id, in: store) + let form = await loadedForm(connection, store: store) + + form.updateJumpHost(secondHop.id) { $0.keyContent = otherKey } + #expect(form.hasChanges) + #expect(form.changesSecrets) + #expect(form.reconnectsAfterSave) + + form.updateJumpHost(secondHop.id) { $0.keyContent = hopKey } + #expect(form.hasChanges == false) + #expect(form.reconnectsAfterSave == false) + + form.updateJumpHost(firstHop.id) { $0.host = "bastion1-new.example.com" } + #expect(form.hasChanges) + } + + @Test("Save stays off while a jump host has no host") + func canSaveNeedsEveryHopHost() { + let form = ConnectionFormViewModel() + form.host = "10.0.0.5" + form.sshEnabled = true + #expect(form.canSave) + + let added = form.addJumpHost() + #expect(form.canSave == false) + + form.updateJumpHost(added) { $0.host = " " } + #expect(form.canSave == false) + + form.updateJumpHost(added) { $0.host = "bastion.example.com" } + #expect(form.canSave) + + form.updateJumpHost(added) { $0.host = "" } + form.sshEnabled = false + #expect(form.canSave) + } + + @Test("Moving hops follows the list's drag order") + func moveFollowsDragOrder() { + let form = ConnectionFormViewModel(editing: chained()) + let third = form.addJumpHost() + + form.moveJumpHosts(fromOffsets: IndexSet(integer: 0), toOffset: 3) + let afterFirstMove = form.jumpHosts.map { $0.id } + #expect(afterFirstMove == [secondHop.id, third, firstHop.id]) + + form.moveJumpHosts(fromOffsets: IndexSet(integer: 2), toOffset: 0) + let afterSecondMove = form.jumpHosts.map { $0.id } + #expect(afterSecondMove == [firstHop.id, secondHop.id, third]) + } + + @Test("A picked text key file fills the hop's key, and a file that is not text is refused") + func pickedKeyFile() throws { + let fixture = try AppStateFixture() + let form = fixture.makeFormViewModel(editing: chained()) + let textKey = fixture.root.appendingPathComponent("id_ed25519") + try Data(hopKey.utf8).write(to: textKey) + let binaryKey = fixture.root.appendingPathComponent("id_binary") + try Data([0xFF, 0xFE, 0x00, 0x81]).write(to: binaryKey) + + form.updateJumpHost(firstHop.id) { $0.usesServerKey = true } + #expect(form.importJumpHostKey(.success([textKey]), for: firstHop.id)) + #expect(form.jumpHost(firstHop.id)?.keyContent == hopKey) + #expect(form.jumpHost(firstHop.id)?.usesServerKey == false) + + #expect(form.importJumpHostKey(.success([binaryKey]), for: secondHop.id) == false) + #expect(form.jumpHostKeyFileError != nil) + #expect(form.jumpHost(secondHop.id)?.keyContent.isEmpty == true) + } +} diff --git a/apple/TableProMobile/TableProMobileTests/ConnectionFormViewModelSSHKeyTests.swift b/apple/TableProMobile/TableProMobileTests/ConnectionFormViewModelSSHKeyTests.swift index 73a26a9064..1a0c0dea74 100644 --- a/apple/TableProMobile/TableProMobileTests/ConnectionFormViewModelSSHKeyTests.swift +++ b/apple/TableProMobile/TableProMobileTests/ConnectionFormViewModelSSHKeyTests.swift @@ -143,6 +143,63 @@ struct ConnectionFormViewModelSSHKeyTests { ) } + @Test("An SSH Agent tunnel shows Private Key, stores a pasted key and keeps SSH Agent for the Mac") + func agentTunnelSignsInWithDeviceKey() throws { + var connection = makeTunnelledConnection() + connection.sshConfiguration?.authMethod = .sshAgent + let store = MockSecureStore() + let form = ConnectionFormViewModel(editing: connection) + + #expect(form.sshAuthMethod == .privateKey) + #expect(form.sshKeyInputMode == .paste) + #expect(form.sshAuthMethodNote != nil) + + form.sshKeyContent = pastedKey + try form.persistPrivateKey(secureStore: store) + + #expect(try store.retrieve(forKey: keyAccount(connection.id)) == pastedKey) + #expect(form.applyingEdits(to: connection).sshConfiguration?.authMethod == .sshAgent) + + form.sshAuthMethod = .password + form.sshAuthMethod = .privateKey + #expect(form.applyingEdits(to: connection).sshConfiguration?.authMethod == .sshAgent) + + form.sshAuthMethod = .password + #expect(form.applyingEdits(to: connection).sshConfiguration?.authMethod == .password) + #expect(form.sshAuthMethodNote == nil) + } + + @Test("Turning on a switched-off SSH Agent tunnel keeps SSH Agent for the Mac") + func enablingAgentTunnelKeepsAgent() { + var connection = makeTunnelledConnection() + connection.sshEnabled = false + connection.sshConfiguration?.authMethod = .sshAgent + let form = ConnectionFormViewModel(editing: connection) + + form.sshEnabled = true + #expect(form.applyingEdits(to: connection).sshConfiguration?.authMethod == .sshAgent) + + form.sshAuthMethod = .password + #expect(form.applyingEdits(to: connection).sshConfiguration?.authMethod == .password) + } + + @Test("A Keyboard Interactive tunnel keeps its method and takes no pasted key") + func keyboardInteractiveKeepsItsMethod() { + var connection = makeTunnelledConnection() + connection.sshConfiguration?.authMethod = .keyboardInteractive + let form = ConnectionFormViewModel(editing: connection) + + #expect(form.sshAuthMethod == .keyboardInteractive) + #expect(form.sshAuthMethodNote != nil) + + form.sshKeyInputMode = .paste + form.sshKeyContent = pastedKey + form.sshPort = "2222" + + #expect(form.pastedPrivateKey == nil) + #expect(form.applyingEdits(to: connection).sshConfiguration?.authMethod == .keyboardInteractive) + } + @Test("Leaving the pasted key behind deletes it on save", arguments: PastedKeyAbandonment.allCases) func switchingAwayDeletesKey(_ change: PastedKeyAbandonment) async throws { let connection = makeTunnelledConnection() diff --git a/apple/TableProMobile/TableProMobileTests/ConnectionLibrary/ConnectionFormEditsTests.swift b/apple/TableProMobile/TableProMobileTests/ConnectionLibrary/ConnectionFormEditsTests.swift index 3d7d31e9b0..f897d62974 100644 --- a/apple/TableProMobile/TableProMobileTests/ConnectionLibrary/ConnectionFormEditsTests.swift +++ b/apple/TableProMobile/TableProMobileTests/ConnectionLibrary/ConnectionFormEditsTests.swift @@ -196,6 +196,188 @@ struct ConnectionFormEditsTests { #expect(saved.sshConfiguration?.macEnabled == true) } + @Test("A tunnel turned on in a new connection is written switched on for the Mac") + func newTunnelWritesMacEnabled() throws { + let viewModel = ConnectionFormViewModel() + viewModel.host = "db.example.com" + viewModel.sshEnabled = true + viewModel.sshHost = "bastion.example.com" + viewModel.sshUsername = "deploy" + + let saved = viewModel.buildConnection() + let encoded = try JSONEncoder().encode(try #require(saved.sshConfiguration)) + let json = try #require(JSONSerialization.jsonObject(with: encoded) as? [String: Any]) + + #expect(saved.sshEnabled) + #expect(saved.sshConfiguration?.macEnabled == true) + #expect(json["enabled"] as? Bool == true) + } + + @Test("An SSH edit on a tunnel that never stored the Mac's switch writes it on") + func sshEditWritesMissingMacEnabled() { + var snapshot = storedConnection() + snapshot.sshEnabled = true + snapshot.sshConfiguration = SSHConfiguration(host: "bastion.example.com", port: 22, username: "deploy") + let viewModel = ConnectionFormViewModel(editing: snapshot) + + viewModel.sshPort = "2222" + let saved = viewModel.applyingEdits(to: snapshot) + + #expect(saved.sshConfiguration?.macEnabled == true) + } + + @Test("Removing, reordering and adding hops keeps each remaining hop's Mac credential") + func hopListEditsKeepMacFieldsById() throws { + let first = SSHJumpHost( + host: "bastion1.example.com", username: "ops", + macAuthMethod: .privateKey, macPrivateKeyPath: "/Users/me/.ssh/bastion1" + ) + let second = SSHJumpHost(host: "bastion2.internal", username: "ops") + let third = SSHJumpHost( + host: "bastion3.internal", port: 2_222, username: "ops", + macAuthMethod: .privateKey, macPrivateKeyPath: "/Users/me/.ssh/bastion3" + ) + var snapshot = storedConnection() + snapshot.sshEnabled = true + snapshot.sshConfiguration = SSHConfiguration( + host: "db-ssh.internal", username: "deploy", jumpHosts: [first, second, third] + ) + let viewModel = ConnectionFormViewModel(editing: snapshot) + + viewModel.removeJumpHosts(atOffsets: IndexSet(integer: 1)) + viewModel.moveJumpHosts(fromOffsets: IndexSet(integer: 1), toOffset: 0) + let added = viewModel.addJumpHost() + viewModel.updateJumpHost(added) { $0.host = "bastion4.internal" } + let saved = try #require(viewModel.applyingEdits(to: snapshot).sshConfiguration) + + let ids = saved.jumpHosts.map { $0.id } + #expect(ids == [third.id, first.id, added]) + #expect(saved.jumpHosts[0] == third) + #expect(saved.jumpHosts[1] == first) + #expect(saved.jumpHosts[2].host == "bastion4.internal") + #expect(saved.jumpHosts[2].port == nil) + #expect(saved.jumpHosts[2].macAuthMethod == .sshAgent) + #expect(saved.jumpHosts[2].macPrivateKeyPath.isEmpty) + } + + @Test("A hop edit keeps a Mac edit to that hop and a hop the Mac added while the form was open") + func hopEditKeepsMacEditsAndMacHops() throws { + let first = SSHJumpHost(host: "bastion1.example.com", username: "ops") + let second = SSHJumpHost(host: "bastion2.internal", username: "ops") + var snapshot = storedConnection() + snapshot.sshEnabled = true + snapshot.sshConfiguration = SSHConfiguration( + host: "db-ssh.internal", username: "deploy", jumpHosts: [first, second] + ) + let viewModel = ConnectionFormViewModel(editing: snapshot) + let macAdded = SSHJumpHost(host: "bastion3.internal", username: "ops") + var current = snapshot + current.sshConfiguration?.jumpHosts[0].host = "bastion1-new.example.com" + current.sshConfiguration?.jumpHosts[0].macAuthMethod = .privateKey + current.sshConfiguration?.jumpHosts[0].macPrivateKeyPath = "/Users/me/.ssh/bastion1" + current.sshConfiguration?.jumpHosts.append(macAdded) + + viewModel.updateJumpHost(first.id) { $0.username = "admin" } + let saved = try #require(viewModel.applyingEdits(to: current).sshConfiguration) + + let ids = saved.jumpHosts.map { $0.id } + #expect(ids == [first.id, second.id, macAdded.id]) + #expect(saved.jumpHosts[0].username == "admin") + #expect(saved.jumpHosts[0].host == "bastion1-new.example.com") + #expect(saved.jumpHosts[0].macAuthMethod == .privateKey) + #expect(saved.jumpHosts[0].macPrivateKeyPath == "/Users/me/.ssh/bastion1") + #expect(saved.jumpHosts[2] == macAdded) + } + + @Test("A hop removed in the form stays removed while one the Mac added survives") + func removedHopStaysRemoved() throws { + let first = SSHJumpHost(host: "bastion1.example.com", username: "ops") + let second = SSHJumpHost(host: "bastion2.internal", username: "ops") + var snapshot = storedConnection() + snapshot.sshEnabled = true + snapshot.sshConfiguration = SSHConfiguration( + host: "db-ssh.internal", username: "deploy", jumpHosts: [first, second] + ) + let viewModel = ConnectionFormViewModel(editing: snapshot) + let macAdded = SSHJumpHost(host: "bastion3.internal", username: "ops") + var current = snapshot + current.sshConfiguration?.jumpHosts.append(macAdded) + + viewModel.removeJumpHosts(atOffsets: IndexSet(integer: 0)) + let saved = try #require(viewModel.applyingEdits(to: current).sshConfiguration) + + let ids = saved.jumpHosts.map { $0.id } + #expect(ids == [second.id, macAdded.id]) + } + + @Test("A hop field edit keeps the chain another device saved while the form was open") + func hopFieldEditKeepsTheRecordsChain() throws { + let first = SSHJumpHost(host: "bastion1.example.com", username: "ops") + let removed = SSHJumpHost(host: "bastion2.internal", username: "ops") + let last = SSHJumpHost(host: "bastion3.internal", username: "ops") + var snapshot = storedConnection() + snapshot.sshEnabled = true + snapshot.sshConfiguration = SSHConfiguration( + host: "db-ssh.internal", username: "deploy", jumpHosts: [first, removed, last] + ) + let viewModel = ConnectionFormViewModel(editing: snapshot) + let inserted = SSHJumpHost(host: "bastion-mid.internal", username: "ops") + var current = snapshot + current.sshConfiguration?.jumpHosts = [first, inserted, last] + + viewModel.updateJumpHost(first.id) { $0.username = "admin" } + viewModel.updateJumpHost(removed.id) { $0.port = "2222" } + let saved = try #require(viewModel.applyingEdits(to: current).sshConfiguration) + + let ids = saved.jumpHosts.map { $0.id } + #expect(ids == [first.id, inserted.id, last.id]) + #expect(saved.jumpHosts[0].username == "admin") + } + + @Test("Reordering and adding hops leaves out a hop removed elsewhere and keeps one inserted elsewhere in place") + func hopListEditMergesTheRecordsChain() throws { + let first = SSHJumpHost(host: "bastion1.example.com", username: "ops") + let removed = SSHJumpHost(host: "bastion2.internal", username: "ops") + let last = SSHJumpHost(host: "bastion3.internal", username: "ops") + var snapshot = storedConnection() + snapshot.sshEnabled = true + snapshot.sshConfiguration = SSHConfiguration( + host: "db-ssh.internal", username: "deploy", jumpHosts: [first, removed, last] + ) + let viewModel = ConnectionFormViewModel(editing: snapshot) + let inserted = SSHJumpHost(host: "bastion-mid.internal", username: "ops") + var current = snapshot + current.sshConfiguration?.jumpHosts = [first, inserted, last] + + viewModel.moveJumpHosts(fromOffsets: IndexSet(integer: 2), toOffset: 0) + let added = viewModel.addJumpHost() + viewModel.updateJumpHost(added) { $0.host = "bastion4.internal" } + let saved = try #require(viewModel.applyingEdits(to: current).sshConfiguration) + + let ids = saved.jumpHosts.map { $0.id } + #expect(ids == [last.id, first.id, inserted.id, added]) + } + + @Test("An SSH edit that leaves the hops alone keeps a hop order the Mac set while the form was open") + func untouchedHopsKeepMacOrder() throws { + let first = SSHJumpHost(host: "bastion1.example.com", username: "ops") + let second = SSHJumpHost(host: "bastion2.internal", username: "ops") + var snapshot = storedConnection() + snapshot.sshEnabled = true + snapshot.sshConfiguration = SSHConfiguration( + host: "db-ssh.internal", username: "deploy", jumpHosts: [first, second] + ) + let viewModel = ConnectionFormViewModel(editing: snapshot) + var current = snapshot + current.sshConfiguration?.jumpHosts = [second, first] + + viewModel.sshPort = "2222" + let saved = try #require(viewModel.applyingEdits(to: current).sshConfiguration) + + #expect(saved.jumpHosts == [second, first]) + #expect(saved.port == 2_222) + } + @Test("Turning SSH off clears the tunnel") func sshOffClearsTheTunnel() { var snapshot = storedConnection() diff --git a/apple/TableProMobile/TableProMobileTests/ConnectionLibrary/ConnectionFormViewModelChangesTests.swift b/apple/TableProMobile/TableProMobileTests/ConnectionLibrary/ConnectionFormViewModelChangesTests.swift index 9c27ffc01f..0cac362919 100644 --- a/apple/TableProMobile/TableProMobileTests/ConnectionLibrary/ConnectionFormViewModelChangesTests.swift +++ b/apple/TableProMobile/TableProMobileTests/ConnectionLibrary/ConnectionFormViewModelChangesTests.swift @@ -325,6 +325,24 @@ struct ConnectionFormViewModelChangesTests { #expect(viewModel.changesSecrets) } + @Test("Loading certificates again, as returning from a jump host does, keeps a removed one removed") + func reloadKeepsRemovedCertificate() throws { + let connection = postgres() + try certificates.store( + PEMDocument.encode(Data([1, 2, 3]), as: .certificate), + role: .certificateAuthority, + for: connection.id + ) + let viewModel = form(editing: connection) + viewModel.loadCertificateSummaries() + + viewModel.removeCertificate(.certificateAuthority) + viewModel.loadCertificateSummaries() + + #expect(viewModel.hasCertificate(.certificateAuthority) == false) + #expect(viewModel.hasChanges) + } + @Test("Saving a rename writes no secret back, so secrets changed on another device survive") func renameLeavesChangedSecretsAlone() async throws { let tunnelled = try #require(existingConnections().first { $0.sshEnabled }) diff --git a/apple/TableProMobile/TableProMobileTests/ConnectionLibrary/ConnectionSecretsTests.swift b/apple/TableProMobile/TableProMobileTests/ConnectionLibrary/ConnectionSecretsTests.swift index f3b39e4f1b..e5183e2b77 100644 --- a/apple/TableProMobile/TableProMobileTests/ConnectionLibrary/ConnectionSecretsTests.swift +++ b/apple/TableProMobile/TableProMobileTests/ConnectionLibrary/ConnectionSecretsTests.swift @@ -67,7 +67,8 @@ struct ConnectionSecretsTests { "com.TablePro.password.", "com.TablePro.sshpassword.", "com.TablePro.keypassphrase.", - "com.TablePro.sshkeydata." + "com.TablePro.sshkeydata.", + "com.TablePro.jumphostkeys." ]) let id = UUID() #expect(ConnectionSecretKind.sshPrivateKey.account(for: id) == "com.TablePro.sshkeydata.\(id.uuidString)") @@ -96,6 +97,30 @@ struct ConnectionSecretsTests { #expect(ConnectionSecretKind.orphanedAccounts([account], keeping: [UUID()]).isEmpty) } + @Test("The orphan sweep never takes jump host keys, which may be the only copy") + func sweepSparesJumpHostKeys() { + let account = ConnectionSecretKind.jumpHostKeys.account(for: UUID()) + + #expect(ConnectionSecretKind.orphanedAccounts([account], keeping: [UUID()]).isEmpty) + } + + @Test("A duplicate gets its own copy of the jump host keys, which outlives the original") + func duplicateCopiesJumpHostKeys() throws { + let source = UUID() + let copy = UUID() + let keys: [UUID: JumpHostCredential] = [ + UUID(): .privateKey(key: "HOP KEY", passphrase: "pass"), + UUID(): .sshServerKey + ] + try JumpHostCredentialStore.save(keys, for: source, in: secureStore) + + #expect(secrets.copy(from: source, to: copy)) + secrets.delete(for: source) + + #expect(try JumpHostCredentialStore.load(for: copy, in: secureStore) == keys) + #expect(try JumpHostCredentialStore.load(for: source, in: secureStore).isEmpty) + } + @Test("The orphan sweep takes nothing before the connections have loaded") func sweepWaitsForTheLibrary() { let account = ConnectionSecretKind.password.account(for: UUID()) diff --git a/apple/TableProMobile/TableProMobileTests/SSH/JumpHostCredentialTests.swift b/apple/TableProMobile/TableProMobileTests/SSH/JumpHostCredentialTests.swift new file mode 100644 index 0000000000..d3b38b0c52 --- /dev/null +++ b/apple/TableProMobile/TableProMobileTests/SSH/JumpHostCredentialTests.swift @@ -0,0 +1,115 @@ +import Foundation +import TableProDatabase +import TableProModels +import Testing + +@testable import TableProMobile + +struct JumpHostCredentialTests { + private let connectionId = UUID() + + @Test("Hop credentials read back as they were saved, under one account for the connection") + func roundTrip() throws { + let store = EphemeralSecureStore() + let first = UUID() + let second = UUID() + let third = UUID() + let credentials: [UUID: JumpHostCredential] = [ + first: .privateKey(key: "-----BEGIN OPENSSH PRIVATE KEY-----", passphrase: "secret"), + second: .privateKey(key: "KEY TWO", passphrase: nil), + third: .sshServerKey + ] + + try JumpHostCredentialStore.save(credentials, for: connectionId, in: store) + + #expect(try JumpHostCredentialStore.load(for: connectionId, in: store) == credentials) + #expect(try store.retrieve(forKey: "com.TablePro.jumphostkeys.\(connectionId.uuidString)") != nil) + } + + @Test("A connection with no stored hop keys reads as an empty map") + func absentReadsEmpty() throws { + #expect(try JumpHostCredentialStore.load(for: connectionId, in: EphemeralSecureStore()).isEmpty) + } + + @Test("A Keychain read that fails is reported, never read as no key") + func readErrorThrows() { + #expect(throws: RefusingSecureStore.ReadFailure.self) { + try JumpHostCredentialStore.load(for: connectionId, in: RefusingSecureStore()) + } + } + + @Test("Stored hop keys this build cannot decode are reported, never read as no key") + func undecodableThrows() throws { + let store = EphemeralSecureStore() + try store.store(#"{"\#(UUID().uuidString)":{"agent":{}}}"#, forKey: ConnectionSecretKind.jumpHostKeys.account(for: connectionId)) + + #expect(throws: DecodingError.self) { + try JumpHostCredentialStore.load(for: connectionId, in: store) + } + } + + @Test("Saving an empty map deletes the item") + func emptyMapDeletes() throws { + let store = EphemeralSecureStore() + try JumpHostCredentialStore.save([UUID(): .sshServerKey], for: connectionId, in: store) + + try JumpHostCredentialStore.save([:], for: connectionId, in: store) + + #expect(try store.retrieve(forKey: ConnectionSecretKind.jumpHostKeys.account(for: connectionId)) == nil) + } + + @Test("The encoded map is what save stores, so a throwaway store reads it back") + func encodeMatchesSave() throws { + let hopId = UUID() + let credentials: [UUID: JumpHostCredential] = [hopId: .privateKey(key: "KEY", passphrase: nil)] + let encoded = try JumpHostCredentialStore.encode(credentials) + let store = EphemeralSecureStore([ConnectionSecretKind.jumpHostKeys.account(for: connectionId): encoded]) + + #expect(try JumpHostCredentialStore.load(for: connectionId, in: store) == credentials) + } + + @Test("A replaced connection's hops take the stored ids by host, port and username, each id once") + func keepingHopIdsMatchesEachStoredHopOnce() throws { + let bastion = SSHJumpHost(host: "bastion.internal", port: nil, username: "jump") + let twin = SSHJumpHost(host: "bastion.internal", port: 22, username: "jump") + let inner = SSHJumpHost(host: "inner.internal", port: 2_222, username: "ops") + let stored = SSHConfiguration(host: "db-ssh.internal", jumpHosts: [bastion, twin, inner]) + let incoming = SSHConfiguration(host: "db-ssh.internal", jumpHosts: [ + SSHJumpHost(host: "inner.internal", port: 2_222, username: "ops"), + SSHJumpHost(host: "bastion.internal", port: 22, username: "jump"), + SSHJumpHost(host: "bastion.internal", port: nil, username: "jump"), + SSHJumpHost(host: "bastion.internal", port: 22, username: "jump"), + SSHJumpHost(host: "inner.internal", port: 2_222, username: "root") + ]) + + let merged = try #require(JumpHostCredentialStore.keepingHopIds(from: stored, into: incoming)) + + let ids = merged.jumpHosts.map(\.id) + #expect(ids[0] == inner.id) + #expect(ids[1] == bastion.id) + #expect(ids[2] == twin.id) + #expect(ids[3] == incoming.jumpHosts[3].id) + #expect(ids[4] == incoming.jumpHosts[4].id) + #expect(merged.jumpHosts.map(\.host) == incoming.jumpHosts.map(\.host)) + } + + @Test("Without a stored or an incoming tunnel the incoming one is kept as it is") + func keepingHopIdsWithoutBothSides() { + let incoming = SSHConfiguration(host: "db-ssh.internal", jumpHosts: [SSHJumpHost(host: "bastion.internal")]) + + #expect(JumpHostCredentialStore.keepingHopIds(from: nil, into: incoming) == incoming) + #expect(JumpHostCredentialStore.keepingHopIds(from: incoming, into: nil) == nil) + } +} + +private final class RefusingSecureStore: SecureStore { + struct ReadFailure: Error {} + + func store(_ value: String, forKey key: String) throws {} + + func retrieve(forKey key: String) throws -> String? { + throw ReadFailure() + } + + func delete(forKey key: String) throws {} +} diff --git a/apple/TableProMobile/TableProMobileTests/SSH/SSHJumpChainTeardownTests.swift b/apple/TableProMobile/TableProMobileTests/SSH/SSHJumpChainTeardownTests.swift new file mode 100644 index 0000000000..36d5a82a32 --- /dev/null +++ b/apple/TableProMobile/TableProMobileTests/SSH/SSHJumpChainTeardownTests.swift @@ -0,0 +1,141 @@ +import Foundation +import os +import TableProSSHTransport +import Testing + +@testable import TableProMobile + +struct SSHJumpChainTeardownTests { + @Test("Every relay is woken and its socket shut down, then every relay is waited on, then each hop is freed once", .timeLimit(.minutes(1))) + func wakesThenWaitsThenFrees() async throws { + let log = TeardownLog() + let chain = try HopChain(count: 3, log: log) + defer { chain.close() } + + let teardown = Task { + await SSHJumpChain.tearDown(chain.hops) { hop in log.append("free \(chain.index(of: hop))") } + } + + await chain.waitForStops(3) + #expect(chain.everyHopSocketReadsEOF()) + #expect(!log.events.contains { $0.hasPrefix("free") }) + + chain.finishRelays() + await teardown.value + + let events = log.events + #expect(events.filter { $0.hasPrefix("stop") } == ["stop 2", "stop 1", "stop 0"]) + #expect(events.filter { $0.hasPrefix("free") } == ["free 2", "free 1", "free 0"]) + let lastStop = try #require(events.lastIndex { $0.hasPrefix("stop") }) + let firstFinish = try #require(events.firstIndex { $0.hasPrefix("finished") }) + let lastFinish = try #require(events.lastIndex { $0.hasPrefix("finished") }) + let firstFree = try #require(events.firstIndex { $0.hasPrefix("free") }) + #expect(lastStop < firstFinish) + #expect(lastFinish < firstFree) + } + + @Test("A relay that already ended is not stopped again, and its hop is still shut down and freed", .timeLimit(.minutes(1))) + func endedRelayIsStillFreed() async throws { + let log = TeardownLog() + let chain = try HopChain(count: 2, log: log) + defer { chain.close() } + chain.finishRelay(0) + + let teardown = Task { + await SSHJumpChain.tearDown(chain.hops) { hop in log.append("free \(chain.index(of: hop))") } + } + await chain.waitForStops(1) + #expect(chain.everyHopSocketReadsEOF()) + chain.finishRelay(1) + await teardown.value + + let events = log.events + #expect(events.filter { $0.hasPrefix("stop") } == ["stop 1"]) + #expect(events.filter { $0.hasPrefix("free") } == ["free 1", "free 0"]) + } +} + +private final class TeardownLog: Sendable { + private let entries = OSAllocatedUnfairLock<[String]>(initialState: []) + + var events: [String] { entries.withLock { $0 } } + + func append(_ event: String) { + entries.withLock { $0.append(event) } + } +} + +// Real fences over real socketpairs. The libssh2 handles are placeholders the recorded free never touches. +private final class HopChain: Sendable { + struct SetupFailure: Error {} + + let hops: [SSHJumpHop] + private let peers: [Int32] + private let stops: AsyncStream + private let log: TeardownLog + + init(count: Int, log: TeardownLog) throws { + let (stops, stopped) = AsyncStream.makeStream() + var hops: [SSHJumpHop] = [] + var peers: [Int32] = [] + for index in 0 ..< count { + var fds: [Int32] = [-1, -1] + guard socketpair(AF_UNIX, SOCK_STREAM, 0, &fds) == 0, + let session = OpaquePointer(bitPattern: 0x1_000 + index), + let channel = OpaquePointer(bitPattern: 0x2_000 + index) else { throw SetupFailure() } + let relay = SSHJumpRelayFence { + log.append("stop \(index)") + stopped.yield(index) + } + hops.append(SSHJumpHop( + session: session, + socketFD: fds[0], + channel: channel, + relay: relay, + sessionQueue: DispatchQueue(label: "com.TablePro.tests.hop.\(index)") + )) + peers.append(fds[1]) + } + self.hops = hops + self.peers = peers + self.stops = stops + self.log = log + } + + func index(of hop: SSHJumpHop) -> Int { + hops.firstIndex { $0.socketFD == hop.socketFD } ?? -1 + } + + func waitForStops(_ expected: Int) async { + var seen = 0 + for await _ in stops { + seen += 1 + if seen == expected { return } + } + } + + func finishRelay(_ index: Int) { + let log = self.log + hops[index].relay.finish { log.append("finished \(index)") } + } + + func finishRelays() { + for index in hops.indices { + finishRelay(index) + } + } + + func everyHopSocketReadsEOF() -> Bool { + peers.allSatisfy { peer in + var pollFD = pollfd(fd: peer, events: Int16(POLLIN), revents: 0) + guard poll(&pollFD, 1, 2_000) > 0 else { return false } + var byte: UInt8 = 0 + return recv(peer, &byte, 1, 0) == 0 + } + } + + func close() { + for hop in hops { Darwin.close(hop.socketFD) } + for peer in peers { Darwin.close(peer) } + } +} diff --git a/apple/TableProMobile/TableProMobileTests/SSH/SSHTunnelFactoryTests.swift b/apple/TableProMobile/TableProMobileTests/SSH/SSHTunnelFactoryTests.swift index 5beb44eca1..10843689a1 100644 --- a/apple/TableProMobile/TableProMobileTests/SSH/SSHTunnelFactoryTests.swift +++ b/apple/TableProMobile/TableProMobileTests/SSH/SSHTunnelFactoryTests.swift @@ -22,9 +22,15 @@ struct SSHTunnelFactoryTests { @Test("Every step that throws releases what the factory had built", arguments: SpyTunnelStages.Step.allCases) func throwingStepDiscardsOnce(step: SpyTunnelStages.Step) async { let stages = SpyTunnelStages(failingAt: step) + let hops = step == .jump ? [Self.bastion] : [] await #expect(throws: SpyFailure.self) { - try await Self.create(stages: stages, authMethod: step.authMethod) + try await Self.create( + stages: stages, + authMethod: step.authMethod, + jumpHosts: hops, + jumpHostCredentials: [Self.bastion.id: .privateKey(key: "HOP KEY", passphrase: nil)] + ) } #expect(stages.discardCount == 1) @@ -73,7 +79,7 @@ struct SSHTunnelFactoryTests { let stages = SpyTunnelStages() await #expect(throws: SSHTunnelError.self) { - try await Self.create(stages: stages, authMethod: .sshAgent) + try await Self.create(stages: stages, authMethod: .keyboardInteractive) } #expect(stages.discardCount == 1) @@ -119,44 +125,340 @@ struct SSHTunnelFactoryTests { #expect(stages.discardCount == 1) } - @Test("A connection through a jump host is refused before anything is dialled") - func jumpHostsAreRefused() async { + // MARK: - Jump hosts + + @Test("Each hop is dialed, verified and signed in to in order, then the SSH server, then forwarding") + func chainDialsInOrder() async throws { + let stages = SpyTunnelStages() + let prompter = HostKeyQuestionRecorder() + let first = SSHJumpHost(host: "bastion1.internal", port: nil, username: "jump1") + let second = SSHJumpHost(host: "bastion2.internal", port: 2_222, username: "jump2") + var config = Self.chainConfig(authMethod: .privateKey) + config.jumpHosts = [first, second] + + let tunnel = try await SSHTunnelFactory.create( + config: config, + remoteHost: "db.internal", + remotePort: 5_432, + credentials: SSHTunnelCredentials(keyPassphrase: "server pass", privateKey: "SERVER KEY"), + jumpHostCredentials: [ + first.id: .privateKey(key: "HOP ONE KEY", passphrase: "hop pass"), + second.id: .sshServerKey + ], + prompter: prompter, + hostKeyStore: Self.temporaryHostKeyStore(), + ensureLocalNetworkAccess: { _ in }, + makeStages: { stages } + ) + + #expect(tunnel === stages.tunnel) + #expect(stages.discardCount == 0) + #expect(prompter.questions == ["bastion1.internal:22", "bastion2.internal:2222", "db-ssh.internal:22"]) + #expect(stages.calls == [ + "connect bastion1.internal:22", + "handshake", + "hostKey", + "key jump1 HOP ONE KEY hop pass", + "jump bastion2.internal:2222", + "handshake", + "hostKey", + "key jump2 SERVER KEY server pass", + "jump db-ssh.internal:22", + "handshake", + "hostKey", + "key deploy SERVER KEY server pass", + "forward" + ]) + } + + @Test("A hop with no key on this device is refused before anything is dialed, naming the hop") + func hopWithoutKeyIsRefusedBeforeDialing() async { + let stages = SpyTunnelStages() + let localNetwork = LocalNetworkRecorder() + + await #expect(throws: SSHTunnelError.jumpHostNeedsKey(host: Self.bastion.host)) { + try await Self.create( + stages: stages, + authMethod: .password, + jumpHosts: [Self.bastion], + localNetwork: localNetwork + ) + } + + #expect(stages.connectCount == 0) + #expect(stages.discardCount == 0) + #expect(localNetwork.probed.isEmpty) + } + + @Test("Same Key over an SSH server that signs in with a password is refused before dialing") + func sameKeyOverPasswordServerIsRefused() async { + let stages = SpyTunnelStages() + + await #expect(throws: SSHTunnelError.jumpHostSharedKeyMissing(host: Self.bastion.host)) { + try await Self.create( + stages: stages, + authMethod: .password, + jumpHosts: [Self.bastion], + jumpHostCredentials: [Self.bastion.id: .sshServerKey] + ) + } + + #expect(stages.connectCount == 0) + } + + @Test( + "Same Key over an SSH server key that is not on this device is refused before dialing", + arguments: [String?.some("~/.ssh/tablepro-tests-no-such-key"), .none] + ) + func sameKeyOverMissingServerKeyIsRefused(privateKeyPath: String?) async { + let stages = SpyTunnelStages() + + await #expect(throws: SSHTunnelError.jumpHostSharedKeyMissing(host: Self.bastion.host)) { + try await Self.create( + stages: stages, + authMethod: .privateKey, + credentials: SSHTunnelCredentials(), + privateKeyPath: privateKeyPath, + jumpHosts: [Self.bastion], + jumpHostCredentials: [Self.bastion.id: .sshServerKey] + ) + } + + #expect(stages.connectCount == 0) + } + + @Test("Same Key signs the hop in with a server key file that is on this device") + func sameKeyTakesAServerKeyFile() async throws { + let keyFile = FileManager.default.temporaryDirectory.appendingPathComponent("id-\(UUID().uuidString)") + try Data("FILE KEY".utf8).write(to: keyFile) + defer { try? FileManager.default.removeItem(at: keyFile) } + let stages = SpyTunnelStages() + + _ = try await Self.create( + stages: stages, + authMethod: .privateKey, + credentials: SSHTunnelCredentials(keyPassphrase: "file pass"), + privateKeyPath: keyFile.path, + jumpHosts: [Self.bastion], + jumpHostCredentials: [Self.bastion.id: .sshServerKey] + ) + + #expect(stages.calls.filter { $0.hasPrefix("keyFile") } == [ + "keyFile \(Self.bastion.username) \(keyFile.path) file pass", + "keyFile deploy \(keyFile.path) file pass" + ]) + } + + @Test("A target with no username is refused before dialing, naming that host", arguments: [true, false]) + func emptyUsernameIsRefused(onTheHop: Bool) async { let stages = SpyTunnelStages() + var hop = Self.bastion var config = Self.config(authMethod: .password) - config.jumpHosts = [SSHJumpHost(host: "bastion.internal", port: 22, username: "jump")] + if onTheHop { + hop.username = "" + } else { + config.username = " " + } + config.jumpHosts = [hop] - await #expect(throws: SSHTunnelError.jumpHostsUnsupported) { + await #expect(throws: SSHTunnelError.usernameMissing(host: onTheHop ? hop.host : config.host)) { try await SSHTunnelFactory.create( config: config, remoteHost: "db.internal", remotePort: 5_432, credentials: Self.credentials(for: .password), + jumpHostCredentials: [hop.id: .privateKey(key: "HOP KEY", passphrase: nil)], prompter: StubPrompter(answer: true), hostKeyStore: Self.temporaryHostKeyStore(), + ensureLocalNetworkAccess: { _ in }, makeStages: { stages } ) } #expect(stages.connectCount == 0) + } + + @Test( + "A port outside 1 to 65535 is refused before dialing, so a channel never traps on it", + arguments: ["hop", "server", "database"] + ) + func outOfRangePortIsRefused(target: String) async { + let stages = SpyTunnelStages() + var hop = Self.bastion + var config = Self.config(authMethod: .password) + var remotePort = 5_432 + let named: String + switch target { + case "hop": + hop.port = 2_147_483_648 + named = "Port 2147483648 of \(hop.host)" + case "server": + config.port = 70_000 + named = "Port 70000 of \(config.host)" + default: + remotePort = 0 + named = "Port 0 of db.internal" + } + config.jumpHosts = [hop] + + await #expect(throws: SSHTunnelError.connectionFailed("\(named) is out of range. Use a value between 1 and 65535.")) { + try await SSHTunnelFactory.create( + config: config, + remoteHost: "db.internal", + remotePort: remotePort, + credentials: Self.credentials(for: .password), + jumpHostCredentials: [hop.id: .privateKey(key: "HOP KEY", passphrase: nil)], + prompter: StubPrompter(answer: true), + hostKeyStore: Self.temporaryHostKeyStore(), + ensureLocalNetworkAccess: { _ in }, + makeStages: { stages } + ) + } + + #expect(stages.connectCount == 0) + } + + @Test("Local Network is checked for the first host the device dials", arguments: [true, false]) + func localNetworkProbesTheFirstHost(throughAHop: Bool) async throws { + let localNetwork = LocalNetworkRecorder() + + _ = try await Self.create( + stages: SpyTunnelStages(), + authMethod: .password, + jumpHosts: throughAHop ? [Self.bastion] : [], + jumpHostCredentials: [Self.bastion.id: .privateKey(key: "HOP KEY", passphrase: nil)], + localNetwork: localNetwork + ) + + #expect(localNetwork.probed == [throughAHop ? Self.bastion.host : "127.0.0.1"]) + } + + @Test("A hop that refuses its key is named in the error, which keeps the reason") + func hopFailureNamesTheHop() async { + let refusal = SSHTunnelError.authenticationFailed("In-memory key authentication failed (error -19)") + let stages = SpyTunnelStages(failingAt: .authenticatePrivateKey, with: refusal) + + await #expect(throws: SSHTunnelError.jumpHostFailed(host: Self.bastion.host, reason: refusal.localizedDescription)) { + 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)") + let stages = SpyTunnelStages(failingAt: .jump, with: refusal) + + await #expect(throws: SSHTunnelError.jumpHostFailed(host: Self.bastion.host, reason: refusal.localizedDescription)) { + 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 failure at the SSH server is not blamed on the hop before it") + func serverFailureIsNotRenamed() async { + let refusal = SSHTunnelError.authenticationFailed("Password authentication failed (error -18)") + let stages = SpyTunnelStages(failingAt: .authenticatePassword, with: refusal) + + await #expect(throws: refusal) { + 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 hop's host key question keeps its own error") + func hopHostKeyErrorIsNotRenamed() async { + let stages = SpyTunnelStages() + + let error = await #expect(throws: SSHTunnelError.self) { + try await Self.create( + stages: stages, + authMethod: .password, + prompter: nil, + jumpHosts: [Self.bastion], + jumpHostCredentials: [Self.bastion.id: .privateKey(key: "HOP KEY", passphrase: nil)] + ) + } + + guard case .some(.hostKeyUnverified) = error else { + Issue.record("expected the host key error, got \(String(describing: error))") + return + } + #expect(stages.discardCount == 1) + } + + // MARK: - Sign-in on the device + + @Test("An SSH server set to SSH Agent on the Mac signs in with the key on this device") + func agentServerSignsInWithTheDeviceKey() async throws { + let stages = SpyTunnelStages() + + _ = try await Self.create(stages: stages, authMethod: .sshAgent) + + #expect(stages.calls.contains("key deploy PRIVATE KEY pass")) #expect(stages.discardCount == 0) } - /// 127.0.0.1 so `LocalNetworkPermission` returns without opening a connection, and a host key - /// store on a fresh temporary path so a trusted key never reaches the device's known_hosts. + @Test("An SSH Agent server with no key on this device releases the session it already built") + func agentWithNoKeyDiscardsOnce() async { + let stages = SpyTunnelStages() + + await #expect(throws: SSHTunnelError.self) { + try await Self.create( + stages: stages, + authMethod: .sshAgent, + credentials: SSHTunnelCredentials(), + privateKeyPath: nil + ) + } + + #expect(stages.discardCount == 1) + } + + // MARK: - Helpers + + private static let bastion = SSHJumpHost(host: "bastion.internal", port: 22, username: "jump") + + // A host key store on a fresh temporary path, so a trusted key never reaches the device's known_hosts. private static func create( stages: SpyTunnelStages, authMethod: SSHConfiguration.SSHAuthMethod, credentials: SSHTunnelCredentials? = nil, prompter: (any ConnectionPrompter)? = StubPrompter(answer: true), - privateKeyPath: String? = "/tmp/id_ed25519" + privateKeyPath: String? = "/tmp/id_ed25519", + jumpHosts: [SSHJumpHost] = [], + jumpHostCredentials: [UUID: JumpHostCredential] = [:], + localNetwork: LocalNetworkRecorder = LocalNetworkRecorder() ) async throws -> SpyTunnel { - try await SSHTunnelFactory.create( - config: config(authMethod: authMethod, privateKeyPath: privateKeyPath), + var tunnelConfig = Self.config(authMethod: authMethod, privateKeyPath: privateKeyPath) + tunnelConfig.jumpHosts = jumpHosts + return try await SSHTunnelFactory.create( + config: tunnelConfig, remoteHost: "db.internal", remotePort: 5_432, credentials: credentials ?? Self.credentials(for: authMethod), + jumpHostCredentials: jumpHostCredentials, prompter: prompter, hostKeyStore: temporaryHostKeyStore(), + ensureLocalNetworkAccess: { localNetwork.probe($0) }, makeStages: { stages } ) } @@ -181,9 +483,13 @@ struct SSHTunnelFactoryTests { ) } + private static func chainConfig(authMethod: SSHConfiguration.SSHAuthMethod) -> SSHConfiguration { + SSHConfiguration(host: "db-ssh.internal", port: nil, username: "deploy", authMethod: authMethod) + } + private static func credentials(for authMethod: SSHConfiguration.SSHAuthMethod) -> SSHTunnelCredentials { switch authMethod { - case .privateKey: + case .privateKey, .sshAgent: return SSHTunnelCredentials(keyPassphrase: "pass", privateKey: "PRIVATE KEY") default: return SSHTunnelCredentials(password: "secret") @@ -205,11 +511,35 @@ nonisolated final class StubPrompter: ConnectionPrompter, Sendable { } } -/// Stands in for the libssh2 session builder. It performs no I/O, so every failure path the -/// factory has is reachable with no SSH server and no network. +nonisolated final class HostKeyQuestionRecorder: ConnectionPrompter, Sendable { + private let asked = OSAllocatedUnfairLock<[String]>(initialState: []) + + var questions: [String] { asked.withLock { $0 } } + + func confirm(_ question: ConnectionQuestion) async -> Bool { + switch question { + case .unknownHostKey(let host, let port, _, _), .changedHostKey(let host, let port, _, _): + asked.withLock { $0.append("\(host):\(port)") } + } + return true + } +} + +nonisolated final class LocalNetworkRecorder: Sendable { + private let hosts = OSAllocatedUnfairLock<[String]>(initialState: []) + + var probed: [String] { hosts.withLock { $0 } } + + func probe(_ host: String) { + hosts.withLock { $0.append(host) } + } +} + +// Stands in for the libssh2 session builder with no I/O, so every failure path is reachable offline. nonisolated final class SpyTunnelStages: SSHTunnelStages, Sendable { nonisolated enum Step: CaseIterable, Sendable { case connect + case jump case handshake case hostKey case authenticatePassword @@ -231,16 +561,19 @@ nonisolated final class SpyTunnelStages: SSHTunnelStages, Sendable { var connects = 0 var destination: SSHForwardDestination? var suspended = false + var calls: [String] = [] } let tunnel = SpyTunnel() private let failingAt: Step? + private let failure: any Error private let suspendingAt: Step? private let state = OSAllocatedUnfairLock(initialState: State()) - init(failingAt: Step? = nil, suspendingAt: Step? = nil) { + init(failingAt: Step? = nil, with failure: any Error = SpyFailure(), suspendingAt: Step? = nil) { self.failingAt = failingAt + self.failure = failure self.suspendingAt = suspendingAt } @@ -250,6 +583,8 @@ nonisolated final class SpyTunnelStages: SSHTunnelStages, Sendable { var forwardedTo: SSHForwardDestination? { state.withLock { $0.destination } } + var calls: [String] { state.withLock { $0.calls } } + func waitUntilSuspended() async { while !state.withLock({ $0.suspended }) { try? await Task.sleep(for: .milliseconds(5)) @@ -258,36 +593,40 @@ nonisolated final class SpyTunnelStages: SSHTunnelStages, Sendable { func connect(host: String, port: Int) async throws { state.withLock { $0.connects += 1 } - try await run(.connect) + try await run(.connect, "connect \(host):\(port)") + } + + func jump(toHost host: String, port: Int) async throws { + try await run(.jump, "jump \(host):\(port)") } func handshake() async throws { - try await run(.handshake) + try await run(.handshake, "handshake") } func hostKey() async throws -> (keyData: Data, keyType: String) { - try await run(.hostKey) + try await run(.hostKey, "hostKey") return (Data([0x01, 0x02]), "ssh-ed25519") } func authenticatePassword(username: String, password: String) async throws { - try await run(.authenticatePassword) + try await run(.authenticatePassword, "password \(username)") } func authenticatePublicKey(username: String, keyPath: String, passphrase: String?) async throws { - try await run(.authenticatePrivateKey) + try await run(.authenticatePrivateKey, "keyFile \(username) \(keyPath) \(passphrase ?? "-")") } func authenticatePublicKeyFromMemory(username: String, keyContent: String, passphrase: String?) async throws { - try await run(.authenticatePrivateKey) + try await run(.authenticatePrivateKey, "key \(username) \(keyContent) \(passphrase ?? "-")") } func authenticateNone(username: String) async throws { - try await run(.authenticateNone) + try await run(.authenticateNone, "none \(username)") } func beginForwarding(destination: SSHForwardDestination) async throws -> SpyTunnel { - try await run(.beginForwarding) + try await run(.beginForwarding, "forward") state.withLock { $0.destination = destination } return tunnel } @@ -296,12 +635,13 @@ nonisolated final class SpyTunnelStages: SSHTunnelStages, Sendable { state.withLock { $0.discards += 1 } } - private func run(_ step: Step) async throws { + private func run(_ step: Step, _ call: String) async throws { + state.withLock { $0.calls.append(call) } if step == suspendingAt { state.withLock { $0.suspended = true } try await Task.sleep(for: .seconds(30)) } - if step == failingAt { throw SpyFailure() } + if step == failingAt { throw failure } } } diff --git a/apple/TableProMobile/project.yml b/apple/TableProMobile/project.yml index 30b884e1f4..22219ca408 100644 --- a/apple/TableProMobile/project.yml +++ b/apple/TableProMobile/project.yml @@ -88,12 +88,13 @@ targets: # The read-write BEGIN both platforms open a write transaction with. - ../Plugins/PostgreSQLDriverPlugin/PostgreSQLTransactionStatement.swift # The libssh2 half of the SSH transport, shared with the macOS app by file reference. - # The C-free half is the TableProSSHTransport package target; these three touch libssh2 + # The C-free half is the TableProSSHTransport package target; these touch libssh2 # and cannot be, because a manifest cannot express the slice-dependent header path the # iOS xcframework needs. Both CLibSSH2 shims declare the same module and the same four # tablepro_* wrappers, so `import CLibSSH2` resolves on either platform. - ../TablePro/Core/SSH/LibSSH2ChannelIO.swift - ../TablePro/Core/SSH/LibSSH2ForwardChannel.swift + - ../TablePro/Core/SSH/LibSSH2JumpRelay.swift - ../TablePro/Core/SSH/SSHKeepAliveResult.swift # License texts behind the Acknowledgements screen, copied whole so each keeps the # texts/ path Acknowledgements.json names it by. diff --git a/apple/TableProTests/Core/SSH/SSHTunnelErrorTests.swift b/apple/TableProTests/Core/SSH/SSHTunnelErrorTests.swift index 1423c5556e..e5366d8c36 100644 --- a/apple/TableProTests/Core/SSH/SSHTunnelErrorTests.swift +++ b/apple/TableProTests/Core/SSH/SSHTunnelErrorTests.swift @@ -332,67 +332,4 @@ struct SSHTunnelErrorTests { } #expect(!lateOperationRan.withLock { $0 }) } - - @Test("ProxyJump cleanup waits until its relay no longer uses libssh2 handles") - func proxyJumpCleanupDrainsRelayBeforeFree() async throws { - let releaseRelay = DispatchSemaphore(value: 0) - let enteredRelay = OSAllocatedUnfairLock(initialState: false) - let relayFinished = OSAllocatedUnfairLock(initialState: false) - let cleanupObservedFinishedRelay = OSAllocatedUnfairLock(initialState: false) - let fence = SSHJumpRelayFence { - releaseRelay.signal() - } - - Thread.detachNewThread { - enteredRelay.withLock { $0 = true } - releaseRelay.wait() - relayFinished.withLock { $0 = true } - fence.finish() - } - - let entryDeadline = ContinuousClock.now + .seconds(1) - while !enteredRelay.withLock({ $0 }), ContinuousClock.now < entryDeadline { - try await Task.sleep(for: .milliseconds(5)) - } - guard enteredRelay.withLock({ $0 }) else { - releaseRelay.signal() - Issue.record("The ProxyJump relay did not start") - return - } - - let teardown = await BoundedCall.resultOnItsOwnThread( - within: .seconds(2), - onDeadline: { releaseRelay.signal() }, - of: { - fence.stop() - fence.wait() - cleanupObservedFinishedRelay.withLock { value in - value = relayFinished.withLock { $0 } - } - return true - } - ) - - #expect(teardown == true) - #expect(cleanupObservedFinishedRelay.withLock { $0 }) - #expect(!fence.isActive) - } - - @Test("A completed ProxyJump relay disarms late descriptor interruption") - func proxyJumpCompletionDisarmsLateStop() { - let stopCount = OSAllocatedUnfairLock(initialState: 0) - let released = OSAllocatedUnfairLock(initialState: false) - let fence = SSHJumpRelayFence { - stopCount.withLock { $0 += 1 } - } - - fence.finish { - released.withLock { $0 = true } - } - fence.stop() - - #expect(released.withLock { $0 }) - #expect(stopCount.withLock { $0 } == 0) - #expect(!fence.isActive) - } } diff --git a/apple/changelog.d/3683.added.md b/apple/changelog.d/3683.added.md new file mode 100644 index 0000000000..d969c4b9eb --- /dev/null +++ b/apple/changelog.d/3683.added.md @@ -0,0 +1 @@ +Connections through SSH jump hosts now open on iPhone and iPad, with a key for each hop kept on the device. diff --git a/apple/changelog.d/3683.fixed.1.md b/apple/changelog.d/3683.fixed.1.md new file mode 100644 index 0000000000..71695dd593 --- /dev/null +++ b/apple/changelog.d/3683.fixed.1.md @@ -0,0 +1 @@ +An SSH tunnel turned on in the iPhone or iPad form stayed off on the Mac. diff --git a/apple/changelog.d/3683.fixed.2.md b/apple/changelog.d/3683.fixed.2.md new file mode 100644 index 0000000000..17bbe679ad --- /dev/null +++ b/apple/changelog.d/3683.fixed.2.md @@ -0,0 +1 @@ +A missing SSH username was reported as a wrong password or key on iPhone and iPad. diff --git a/apple/changelog.d/3683.fixed.3.md b/apple/changelog.d/3683.fixed.3.md new file mode 100644 index 0000000000..01f39da9c7 --- /dev/null +++ b/apple/changelog.d/3683.fixed.3.md @@ -0,0 +1 @@ +A connection or SSH port above 65535 crashed TablePro on iPhone and iPad. diff --git a/apple/changelog.d/3683.fixed.md b/apple/changelog.d/3683.fixed.md new file mode 100644 index 0000000000..8f96811fcf --- /dev/null +++ b/apple/changelog.d/3683.fixed.md @@ -0,0 +1 @@ +An SSH tunnel set to SSH Agent on the Mac could not sign in on iPhone and iPad.