diff --git a/FrameworkTests/SSHConnectionPreparationTests.swift b/FrameworkTests/SSHConnectionPreparationTests.swift index 91077c61..affe8d03 100644 --- a/FrameworkTests/SSHConnectionPreparationTests.swift +++ b/FrameworkTests/SSHConnectionPreparationTests.swift @@ -5,6 +5,27 @@ import Testing @Suite struct SSHConnectionPreparationTests { + @Test func usernameOnlyIdentityReachesServerAuthentication() async throws { + let preparation = makePreparation( + identity: Identity(name: "OTP", username: "deploy"), + secrets: PreparationSecrets(password: nil, key: nil) + ) + let configuration = try await preparation.resolve(trustPolicy: .allowChallenge) + #expect(configuration.username == "deploy") + #expect(configuration.credentials.isEmpty) + } + + @Test func customUsernameOnlySubmissionDoesNotRequireTheHostsSavedPassword() async throws { + var preparation = makePreparation( + identity: Identity(name: "Saved", username: "saved", credentialRef: "saved-password"), + secrets: PreparationSecrets(password: nil, key: nil) + ) + preparation.nodes[0].submission = SSHAuthenticationSubmission(username: "otp-user") + let configuration = try await preparation.resolve(trustPolicy: .allowChallenge) + #expect(configuration.username == "otp-user") + #expect(configuration.credentials.isEmpty) + } + @Test func keychainFailureRemainsDistinctFromMissingCredential() async { let identity = Identity( diff --git a/FrameworkTests/SSHKeyboardInteractiveQueueTests.swift b/FrameworkTests/SSHKeyboardInteractiveQueueTests.swift new file mode 100644 index 00000000..a130fe94 --- /dev/null +++ b/FrameworkTests/SSHKeyboardInteractiveQueueTests.swift @@ -0,0 +1,73 @@ +import Foundation +import TermindApplication +import TermindDomain +import Testing + +@Suite @MainActor +struct SSHKeyboardInteractiveQueueTests { + @Test func concurrentConnectionsKeepAnswersSeparateAndIgnoreRetiredRequests() async throws { + let queue = SSHKeyboardInteractiveQueue() + let first = challenge() + let second = challenge() + let a = Task { try await queue.request(first) } + let b = Task { try await queue.request(second) } + await wait(queue, count: 2) + queue.respond(first.id, answers: [" 验证码 \u{0} "]) + #expect(try await a.value == [" 验证码 \u{0} "]) + queue.respond(first.id, answers: ["stale"]) + queue.respond(second.id, answers: []) + #expect(queue.pending.map(\.id) == [second.id]) + queue.respond(second.id, answers: [""]) + #expect(try await b.value == [""]) + #expect(queue.pending.isEmpty) + } + + @Test func cancellationRemovesOnlyItsOwnPrompt() async throws { + let queue = SSHKeyboardInteractiveQueue() + let first = challenge() + let second = challenge() + let a = Task { try await queue.request(first) } + let b = Task { try await queue.request(second) } + await wait(queue, count: 2) + a.cancel() + await #expect(throws: CancellationError.self) { try await a.value } + #expect(queue.pending.map(\.id) == [second.id]) + queue.cancel(second.id) + await #expect(throws: CancellationError.self) { try await b.value } + } + + @Test func informationalRoundsDoNotRequireAVisibleForm() async throws { + let queue = SSHKeyboardInteractiveQueue() + var challenge = challenge() + challenge.prompts = [] + #expect(try await queue.request(challenge) == []) + #expect(queue.pending.isEmpty) + } + + @Test func disconnectCancelsTheJourneyWithoutLoggingChallenges() async throws { + let controller = RemoteSessionConnectionController() + let challenge = challenge() + let host = Host(id: challenge.hostID, name: "Host", host: challenge.hostname) + controller.journey.begin(host: host, route: [host], purpose: .sshSession, username: challenge.username) + let task = Task { try await controller.answerKeyboardInteractive(challenge) } + await wait(controller.keyboardAuthentication, count: 1) + #expect(controller.journey.phase == .waiting) + #expect(!controller.journey.plainTextLog.contains(challenge.instruction)) + controller.disconnect() + await #expect(throws: CancellationError.self) { try await task.value } + #expect(controller.journey.phase == .hidden) + } + + private func challenge() -> SSHKeyboardInteractiveChallenge { + SSHKeyboardInteractiveChallenge( + connectionID: UUID(), hostID: UUID(), hostname: "server.example", port: 22, + username: "deploy", round: 1, name: "PAM", instruction: "Private server challenge", + prompts: [.init(text: "OTP", echo: false)] + ) + } + + private func wait(_ queue: SSHKeyboardInteractiveQueue, count: Int) async { + for _ in 0..<1_000 where queue.pending.count != count { await Task.yield() } + #expect(queue.pending.count == count) + } +} diff --git a/Frameworks/Resources/Localizable.xcstrings b/Frameworks/Resources/Localizable.xcstrings index 99fbc3ed..b2096a62 100644 --- a/Frameworks/Resources/Localizable.xcstrings +++ b/Frameworks/Resources/Localizable.xcstrings @@ -1,6 +1,70 @@ { "sourceLanguage" : "en", "strings" : { + "Invalid authentication challenge." : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "认证质询无效。" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "驗證詢問無效。" + } + } + } + }, + "Invalid authentication response." : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "认证响应无效。" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "驗證回應無效。" + } + } + } + }, + "This server requires interactive authentication." : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "此服务器需要交互式认证。" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "此伺服器需要互動式驗證。" + } + } + } + }, + "Interactive authentication" : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "交互式认证" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "互動式驗證" + } + } + } + }, "" : { "localizations" : { "zh-Hans" : { diff --git a/Frameworks/TermindApplication/Agent/AgentSSHInteraction.swift b/Frameworks/TermindApplication/Agent/AgentSSHInteraction.swift index d8d36d16..4c8d4c47 100644 --- a/Frameworks/TermindApplication/Agent/AgentSSHInteraction.swift +++ b/Frameworks/TermindApplication/Agent/AgentSSHInteraction.swift @@ -11,6 +11,7 @@ import struct TermindDomain.Host nonisolated public struct AgentSSHAuthenticationRequest: Identifiable, Hashable, Sendable { public nonisolated enum Mode: Hashable, Sendable { case identity + case keyboardInteractive(SSHKeyboardInteractiveChallenge) case passphrase(keyID: SSHKeyRef.ID) } @@ -30,6 +31,10 @@ nonisolated public struct AgentSSHAuthenticationRequest: Identifiable, Hashable, if case .passphrase = mode { true } else { false } } + public var keyboardInteractive: SSHKeyboardInteractiveChallenge? { + if case .keyboardInteractive(let challenge) = mode { challenge } else { nil } + } + public var journeyPrompt: SSHAuthenticationPrompt { SSHAuthenticationPrompt( host: Host(id: hostID, name: displayName, host: address), @@ -38,7 +43,8 @@ nonisolated public struct AgentSSHAuthenticationRequest: Identifiable, Hashable, selectedKeyID: selectedKeyID, failureMessage: failureMessage, requiresPassphrase: requiresPassphrase, - mode: failureMessage == nil ? .localIdentity : .serverChallenge + mode: failureMessage == nil ? .localIdentity : .serverChallenge, + keyboardInteractive: keyboardInteractive ) } @@ -88,6 +94,7 @@ nonisolated public struct AgentSSHAuthenticationAnswer: Hashable, Sendable { nonisolated public enum AgentSSHAuthenticationResponse: Hashable, Sendable { case submit(AgentSSHAuthenticationAnswer) + case keyboardInteractive([String]) case cancelled } @@ -157,6 +164,22 @@ nonisolated public struct AgentSSHInteraction: Sendable { authenticate: { _ in .cancelled }, confirmHostKey: { _ in .deny } ) + public func respondToChallenge( + _ challenge: SSHKeyboardInteractiveChallenge, invocationID: AgentToolInvocation.ID + ) async throws -> [String] { + try Task.checkCancellation() + let response = await authenticate( + AgentSSHAuthenticationRequest( + id: challenge.id, invocationID: invocationID, hostID: challenge.hostID, + displayName: challenge.hostname, address: challenge.address, + mode: .keyboardInteractive(challenge), username: challenge.username + ) + ) + try Task.checkCancellation() + guard case .keyboardInteractive(let answers) = response else { throw CancellationError() } + return answers + } + public init(authenticate: @escaping Authenticating, confirmHostKey: @escaping HostKeyConfirming) { self.authenticate = authenticate self.confirmHostKey = confirmHostKey diff --git a/Frameworks/TermindApplication/Agent/ConversationModel.swift b/Frameworks/TermindApplication/Agent/ConversationModel.swift index 9b69546d..4a9aa738 100644 --- a/Frameworks/TermindApplication/Agent/ConversationModel.swift +++ b/Frameworks/TermindApplication/Agent/ConversationModel.swift @@ -2051,6 +2051,10 @@ public final class ConversationModel: Identifiable { ) async -> AgentSSHAuthenticationResponse { await withTaskCancellationHandler { await withCheckedContinuation { + guard !Task.isCancelled else { + $0.resume(returning: .cancelled) + return + } sshAuthenticationWaiters[request.id] = $0 let index = pendingSSHAuthentications.firstIndex { $0.createdAt > request.createdAt @@ -2059,15 +2063,24 @@ public final class ConversationModel: Identifiable { request, at: index ?? pendingSSHAuthentications.endIndex ) setToolState(request.invocationID, .waitingForUser) + if request.keyboardInteractive?.prompts.isEmpty == true { + Task { [weak self] in + await Task.yield() + self?.respondToSSHAuthentication(.keyboardInteractive([]), requestID: request.id) + } + } } } onCancel: { Task { @MainActor [weak self] in self?.cancelSSHAuthentication(request.id) } } } - public func respondToSSHAuthentication(_ response: AgentSSHAuthenticationResponse) { - guard !pendingSSHAuthentications.isEmpty else { return } - let request = pendingSSHAuthentications.removeFirst() + public func respondToSSHAuthentication(_ response: AgentSSHAuthenticationResponse, requestID: UUID? = nil) { + guard + let index = requestID.map({ id in pendingSSHAuthentications.firstIndex { $0.id == id } }) + ?? (pendingSSHAuthentications.isEmpty ? nil : 0) + else { return } + let request = pendingSSHAuthentications.remove(at: index) setToolState(request.invocationID, .running) sshAuthenticationWaiters.removeValue(forKey: request.id)?.resume(returning: response) } diff --git a/Frameworks/TermindApplication/Connections/RemoteSessionConnectionController.swift b/Frameworks/TermindApplication/Connections/RemoteSessionConnectionController.swift index eb87bcff..2a1fdee8 100644 --- a/Frameworks/TermindApplication/Connections/RemoteSessionConnectionController.swift +++ b/Frameworks/TermindApplication/Connections/RemoteSessionConnectionController.swift @@ -9,6 +9,8 @@ import struct TermindDomain.Host @Observable public final class RemoteSessionConnectionController { public let journey = ConnectionJourney() + public let keyboardAuthentication = SSHKeyboardInteractiveQueue() + private var keyboardAuthenticationID: UUID? public let reconnect: SessionReconnectController public private(set) var pendingHostKey: SSHHostKeyPrompt? public var temporaryHostKeys: [Host.ID: SSHHostKeyIdentity] = [:] @@ -25,6 +27,8 @@ public final class RemoteSessionConnectionController { } public func disconnect() { + keyboardAuthenticationID = nil + keyboardAuthentication.cancelAll() finishHostKeyAuthorization(.reject) temporaryHostKeys.removeAll() reconnect.stop() @@ -104,6 +108,20 @@ public final class RemoteSessionConnectionController { public func rejectHostKey() { finishHostKeyAuthorization(.reject) } + public func answerKeyboardInteractive(_ challenge: SSHKeyboardInteractiveChallenge) async throws -> [String] { + try Task.checkCancellation() + keyboardAuthenticationID = challenge.id + reconnect.pauseForUserInteraction() + journey.waitForAuthentication(at: challenge.hostID) + defer { + if keyboardAuthenticationID == challenge.id { + keyboardAuthenticationID = nil + journey.resume() + } + } + return try await keyboardAuthentication.request(challenge) + } + public func authorizeHostKey( _ challenge: SSHHostKeyChallenge, using store: any RemoteSessionHostStore diff --git a/Frameworks/TermindApplication/Connections/SSHAuthenticationPrompt.swift b/Frameworks/TermindApplication/Connections/SSHAuthenticationPrompt.swift index 8f2e4596..b1f595bf 100644 --- a/Frameworks/TermindApplication/Connections/SSHAuthenticationPrompt.swift +++ b/Frameworks/TermindApplication/Connections/SSHAuthenticationPrompt.swift @@ -15,11 +15,13 @@ public nonisolated struct SSHAuthenticationPrompt: Identifiable, Hashable, Senda public var username: String public var selectedKeyID: SSHKeyRef.ID? public var failureMessage: String? + public var keyboardInteractive: SSHKeyboardInteractiveChallenge? public var requiresPassphrase = false public var mode: Mode = .localIdentity public init( host: Host, selectedIdentityID: Identity.ID? = nil, username: String, selectedKeyID: SSHKeyRef.ID? = nil, - failureMessage: String? = nil, requiresPassphrase: Bool = false, mode: Mode = .localIdentity + failureMessage: String? = nil, requiresPassphrase: Bool = false, mode: Mode = .localIdentity, + keyboardInteractive: SSHKeyboardInteractiveChallenge? = nil ) { self.host = host self.selectedIdentityID = selectedIdentityID @@ -28,6 +30,7 @@ public nonisolated struct SSHAuthenticationPrompt: Identifiable, Hashable, Senda self.failureMessage = failureMessage self.requiresPassphrase = requiresPassphrase self.mode = mode + self.keyboardInteractive = keyboardInteractive } } diff --git a/Frameworks/TermindApplication/Connections/SSHConnectionPreparation.swift b/Frameworks/TermindApplication/Connections/SSHConnectionPreparation.swift index 97a91a6e..e47d8d52 100644 --- a/Frameworks/TermindApplication/Connections/SSHConnectionPreparation.swift +++ b/Frameworks/TermindApplication/Connections/SSHConnectionPreparation.swift @@ -152,7 +152,10 @@ public nonisolated struct SSHConnectionPreparation: Sendable { } } if result.isEmpty, let retrievalFailure { throw retrievalFailure } - guard !result.isEmpty else { throw TermindSSHError.missingCredential } + let usesIdentity = node.submission == nil || node.submission?.identityID != nil + if result.isEmpty, node.key != nil || (usesIdentity && node.identity?.hasSecret == true) { + throw TermindSSHError.missingCredential + } return (username, result) } diff --git a/Frameworks/TermindApplication/Connections/SSHKeyboardInteractiveChallenge.swift b/Frameworks/TermindApplication/Connections/SSHKeyboardInteractiveChallenge.swift new file mode 100644 index 00000000..38dace7d --- /dev/null +++ b/Frameworks/TermindApplication/Connections/SSHKeyboardInteractiveChallenge.swift @@ -0,0 +1,112 @@ +import Foundation +import TermindDomain + +/// Server-authored authentication data. Neither challenges nor answers belong +/// in an Agent transcript, connection log, or persistent credential store. +public nonisolated struct SSHKeyboardInteractiveChallenge: Identifiable, Hashable, Sendable { + public struct Prompt: Hashable, Sendable { + public var text: String + public var echo: Bool + + public init(text: String, echo: Bool) { + self.text = text + self.echo = echo + } + } + + public var id: UUID + public var connectionID: UUID + public var hostID: UUID + public var hostname: String + public var port: Int + public var username: String + public var round: Int + public var name: String + public var instruction: String + public var prompts: [Prompt] + + public var address: String { + let host = hostname.contains(":") ? "[\(hostname)]" : hostname + return "\(username)@\(host):\(port)" + } + + public init( + id: UUID = UUID(), connectionID: UUID, hostID: UUID, hostname: String, + port: Int, username: String, round: Int, name: String, instruction: String, + prompts: [Prompt] + ) { + self.id = id + self.connectionID = connectionID + self.hostID = hostID + self.hostname = hostname + self.port = port + self.username = username + self.round = round + self.name = name + self.instruction = instruction + self.prompts = prompts + } +} + +public typealias SSHKeyboardInteractiveHandler = + @Sendable (SSHKeyboardInteractiveChallenge) async throws -> [String] + +/// An operation-owned queue can also be retained by an app for independent +/// transfers. Cancellation removes exactly the requesting connection's prompt. +@Observable +public final class SSHKeyboardInteractiveQueue { + public private(set) var pending: [SSHKeyboardInteractiveChallenge] = [] + private var waiters: [UUID: CheckedContinuation<[String], any Error>] = [:] + + public var current: SSHKeyboardInteractiveChallenge? { pending.first } + + public init() {} + + public nonisolated var handler: SSHKeyboardInteractiveHandler { + { [weak self] challenge in + guard let self else { throw CancellationError() } + return try await self.request(challenge) + } + } + + public func request(_ challenge: SSHKeyboardInteractiveChallenge) async throws -> [String] { + try Task.checkCancellation() + return try await withTaskCancellationHandler { + try await withCheckedThrowingContinuation { continuation in + guard !Task.isCancelled else { + continuation.resume(throwing: CancellationError()) + return + } + waiters[challenge.id] = continuation + pending.append(challenge) + if challenge.prompts.isEmpty { + Task { [weak self] in + await Task.yield() + self?.respond(challenge.id, answers: []) + } + } + } + } onCancel: { + Task { @MainActor [weak self] in self?.cancel(challenge.id) } + } + } + + public func respond(_ id: UUID, answers: [String]) { + guard let challenge = pending.first(where: { $0.id == id }), + answers.count == challenge.prompts.count + else { return } + pending.removeAll { $0.id == id } + waiters.removeValue(forKey: id)?.resume(returning: answers) + } + + public func cancel(_ id: UUID) { + pending.removeAll { $0.id == id } + waiters.removeValue(forKey: id)?.resume(throwing: CancellationError()) + } + + public func cancelAll(hostID: UUID? = nil) { + for challenge in pending where hostID == nil || challenge.hostID == hostID { + cancel(challenge.id) + } + } +} diff --git a/Frameworks/TermindApplication/FeatureModels/HostLibraryModel.swift b/Frameworks/TermindApplication/FeatureModels/HostLibraryModel.swift index 4f8a56db..0cc6f315 100644 --- a/Frameworks/TermindApplication/FeatureModels/HostLibraryModel.swift +++ b/Frameworks/TermindApplication/FeatureModels/HostLibraryModel.swift @@ -366,7 +366,7 @@ public final class HostLibraryModel: RemoteSessionHostStore { identity.keyID.flatMap { key($0) }.map { keyAvailability(for: $0) == .available } == true - guard hasAvailablePassword || hasUsableKey else { + guard !identity.hasSecret || hasAvailablePassword || hasUsableKey else { return String(localized: "No saved credential") } let knownHosts = knownHosts(for: host) @@ -453,7 +453,10 @@ public final class HostLibraryModel: RemoteSessionHostStore { } credentials = resolution.credentials } - guard !credentials.isEmpty else { throw TermindSSHError.missingCredential } + let expectsCredential = + submissions[host.id].map { $0.keyID != nil || $0.password != nil } + ?? (identity(for: host)?.hasSecret == true) + if credentials.isEmpty, expectsCredential { throw TermindSSHError.missingCredential } let expectedHostKeys = try expectedHostKeys( for: host, temporaryHostKeys: temporaryHostKeys, trustPolicy: trustPolicy @@ -553,7 +556,7 @@ public final class HostLibraryModel: RemoteSessionHostStore { guard resolution.requiredPassphraseKeyID == nil else { throw TermindSSHError.keyPassphraseRequired } - guard !resolution.credentials.isEmpty else { throw TermindSSHError.missingCredential } + guard !identity.hasSecret || !resolution.credentials.isEmpty else { throw TermindSSHError.missingCredential } return SSHRouteHopConfiguration( id: host.id, name: host.name, host: host.host, port: host.port, username: username, credentials: resolution.credentials, @@ -579,7 +582,7 @@ public final class HostLibraryModel: RemoteSessionHostStore { let credentials = try sshCredentials( for: submission, accessedIdentityIDs: &accessedIdentityIDs ) - guard !credentials.isEmpty else { throw TermindSSHError.missingCredential } + guard submission.keyID == nil || !credentials.isEmpty else { throw TermindSSHError.missingCredential } return SSHRouteHopConfiguration( id: host.id, name: host.name, host: host.host, port: host.port, username: username, credentials: credentials, diff --git a/Frameworks/TermindApplication/Metrics/ServerMetricsModel.swift b/Frameworks/TermindApplication/Metrics/ServerMetricsModel.swift index c8f126e0..7f3cdf7e 100644 --- a/Frameworks/TermindApplication/Metrics/ServerMetricsModel.swift +++ b/Frameworks/TermindApplication/Metrics/ServerMetricsModel.swift @@ -85,6 +85,8 @@ public final class ServerMetricsModel: MetricsSnapshotReading { } private var pendingHostKeyAuthorizations: [UUID: PendingHostKeyAuthorization] = [:] + private nonisolated let keyboardInteractive: SSHKeyboardInteractiveHandler? + public var keepsRunning: Bool { servers.contains { $0.selection.enabled } } public init( @@ -92,8 +94,10 @@ public final class ServerMetricsModel: MetricsSnapshotReading { makeSampler: @escaping @Sendable () -> any MetricsSampling, clock: MetricsClock = MetricsClock(), cardLimit: @escaping () -> Int? = { ServerMetricsModel.freeCardLimit }, - limitReached: @escaping () -> Void = {} + limitReached: @escaping () -> Void = {}, + keyboardInteractive: SSHKeyboardInteractiveHandler? = nil ) { + self.keyboardInteractive = keyboardInteractive self.hosts = hosts self.preferences = preferences self.makeSampler = makeSampler @@ -542,7 +546,8 @@ public final class ServerMetricsModel: MetricsSnapshotReading { return await self.authorizeHostKeyFromWorker( serverID, challenge: challenge, token: token ) - } + }, + keyboardInteractive: keyboardInteractive ) ) guard await publishConnected(serverID, token: token, hostID: attempt.host.id) else { diff --git a/Frameworks/TermindApplication/SSHConnectionTypes.swift b/Frameworks/TermindApplication/SSHConnectionTypes.swift index d5b7abb5..75f4a2e1 100644 --- a/Frameworks/TermindApplication/SSHConnectionTypes.swift +++ b/Frameworks/TermindApplication/SSHConnectionTypes.swift @@ -54,13 +54,22 @@ public nonisolated enum SSHHostTrustPolicy: Equatable, Sendable { public nonisolated struct SSHConnectionRequest: Sendable { public var configuration: SSHConnectionConfiguration public var authorizeHostKey: SSHHostKeyAuthorizer? + public var keyboardInteractive: SSHKeyboardInteractiveHandler? public init( configuration: SSHConnectionConfiguration, - authorizeHostKey: SSHHostKeyAuthorizer? = nil + authorizeHostKey: SSHHostKeyAuthorizer? = nil, + keyboardInteractive: SSHKeyboardInteractiveHandler? = nil ) { self.configuration = configuration self.authorizeHostKey = authorizeHostKey + self.keyboardInteractive = keyboardInteractive + } + + public func withKeyboardInteractiveFallback(_ handler: SSHKeyboardInteractiveHandler?) -> Self { + var request = self + if request.keyboardInteractive == nil { request.keyboardInteractive = handler } + return request } } diff --git a/Frameworks/TermindPresentation/ConnectionDecisionForms.swift b/Frameworks/TermindPresentation/ConnectionDecisionForms.swift index 589c901b..255f7844 100644 --- a/Frameworks/TermindPresentation/ConnectionDecisionForms.swift +++ b/Frameworks/TermindPresentation/ConnectionDecisionForms.swift @@ -9,7 +9,7 @@ public protocol ConnectionFormKeyboardAdapting { extension View { @ViewBuilder - fileprivate func connectionFormChrome(isEnabled: Bool) -> some View { + func connectionFormChrome(isEnabled: Bool) -> some View { if isEnabled { padding(18).background(.regularMaterial, in: RoundedRectangle(cornerRadius: 14)) .overlay { RoundedRectangle(cornerRadius: 14).strokeBorder(.quaternary) } @@ -23,6 +23,7 @@ public struct SSHAuthenticationForm: View { let prompt: SSHAuthenticationPrompt let submit: (Identity.ID?, String, String?, String?, SSHKeyRef.ID?, Bool) -> Void let cancel: () -> Void + let respondKeyboardInteractive: (([String]) -> Void)? var usesContainerChrome = true let library: HostLibraryModel @@ -41,8 +42,10 @@ public struct SSHAuthenticationForm: View { submit: @escaping (Identity.ID?, String, String?, String?, SSHKeyRef.ID?, Bool) -> Void, cancel: @escaping () -> Void, usesContainerChrome: Bool = true, - library: HostLibraryModel, keyboard: (any ConnectionFormKeyboardAdapting)? = nil + library: HostLibraryModel, keyboard: (any ConnectionFormKeyboardAdapting)? = nil, + respondKeyboardInteractive: (([String]) -> Void)? = nil ) { + self.respondKeyboardInteractive = respondKeyboardInteractive self.library = library self.keyboard = keyboard self.prompt = prompt @@ -70,17 +73,25 @@ public struct SSHAuthenticationForm: View { } public var body: some View { - form - .connectionFormChrome(isEnabled: usesContainerChrome) - .background { - if let keyboard { - keyboard.bridge(canSubmit: canContinue, submit: { commit(save: !needsPassphrase) }, cancel: cancel) - } + Group { + if let challenge = prompt.keyboardInteractive, let respondKeyboardInteractive { + SSHKeyboardInteractiveForm( + challenge: challenge, submit: respondKeyboardInteractive, cancel: cancel, keyboard: keyboard + ) + } else { + form } - .onAppear { - if identityID != nil, selectedIdentity == nil { identityID = nil } - updateFocusForRequirement() + } + .connectionFormChrome(isEnabled: usesContainerChrome) + .background { + if prompt.keyboardInteractive == nil, let keyboard { + keyboard.bridge(canSubmit: canContinue, submit: { commit(save: !needsPassphrase) }, cancel: cancel) } + } + .onAppear { + if identityID != nil, selectedIdentity == nil { identityID = nil } + updateFocusForRequirement() + } } private var form: some View { diff --git a/Frameworks/TermindPresentation/SSHKeyboardInteractiveForm.swift b/Frameworks/TermindPresentation/SSHKeyboardInteractiveForm.swift new file mode 100644 index 00000000..07d59ecd --- /dev/null +++ b/Frameworks/TermindPresentation/SSHKeyboardInteractiveForm.swift @@ -0,0 +1,97 @@ +import SwiftUI +import TermindApplication + +public struct SSHKeyboardInteractiveForm: View { + private let challenge: SSHKeyboardInteractiveChallenge + private let submit: ([String]) -> Void + private let cancel: () -> Void + private let keyboard: (any ConnectionFormKeyboardAdapting)? + private let usesContainerChrome: Bool + @State private var answers: [String] + @State private var submitted = false + @FocusState private var focus: Int? + + public init( + challenge: SSHKeyboardInteractiveChallenge, submit: @escaping ([String]) -> Void, + cancel: @escaping () -> Void, keyboard: (any ConnectionFormKeyboardAdapting)? = nil, + usesContainerChrome: Bool = false + ) { + self.challenge = challenge + self.submit = submit + self.cancel = cancel + self.keyboard = keyboard + self.usesContainerChrome = usesContainerChrome + _answers = State(initialValue: Array(repeating: "", count: challenge.prompts.count)) + } + + public var body: some View { + VStack(alignment: .leading, spacing: 14) { + Text("Interactive authentication").font(.headline) + Text(verbatim: challenge.address).font(.caption.monospaced()).foregroundStyle(.secondary) + ScrollView { + VStack(alignment: .leading, spacing: 12) { + if !challenge.name.isEmpty { Text(verbatim: challenge.name).font(.headline) } + if !challenge.instruction.isEmpty { + Text(verbatim: challenge.instruction).textSelection(.enabled) + } + ForEach(challenge.prompts.indices, id: \.self) { index in + VStack(alignment: .leading, spacing: 5) { + Text(verbatim: challenge.prompts[index].text) + Group { + if challenge.prompts[index].echo { + TextField("", text: $answers[index]) + } else { + SecureField("", text: $answers[index]) + } + } + .focused($focus, equals: index) + .accessibilityLabel(Text(verbatim: challenge.prompts[index].text)) + .onSubmit { + if index + 1 < answers.count { focus = index + 1 } else { commit() } + } + } + } + } + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.vertical, 4) + } + .frame(maxHeight: 360) + .fixedSize(horizontal: false, vertical: true) + .textFieldStyle(.roundedBorder) + #if os(iOS) + .textInputAutocapitalization(.never) + .autocorrectionDisabled() + #endif + HStack { + Button("Cancel", action: cancel).keyboardShortcut(.cancelAction) + Spacer() + if !challenge.prompts.isEmpty { + Button("Continue", action: commit) + .buttonStyle(.borderedProminent) + .keyboardShortcut(.defaultAction) + .disabled(submitted) + } + } + } + .connectionFormChrome(isEnabled: usesContainerChrome) + .background { + if let keyboard { + keyboard.bridge(canSubmit: !submitted, submit: commit, cancel: cancel) + } + } + .task { + focus = answers.isEmpty ? nil : 0 + // RFC 4256 also permits informational rounds without any questions. + if challenge.prompts.isEmpty { commit() } + } + .onDisappear { answers = Array(repeating: "", count: answers.count) } + } + + private func commit() { + guard !submitted else { return } + submitted = true + let response = answers + answers = Array(repeating: "", count: answers.count) + submit(response) + } +} diff --git a/Frameworks/TermindRemoteInfrastructure/Agent/AgentConnections.swift b/Frameworks/TermindRemoteInfrastructure/Agent/AgentConnections.swift index 312afcc0..6e6505fe 100644 --- a/Frameworks/TermindRemoteInfrastructure/Agent/AgentConnections.swift +++ b/Frameworks/TermindRemoteInfrastructure/Agent/AgentConnections.swift @@ -222,7 +222,7 @@ public final class AgentHostResolver: Sendable { failureMessage: failure ) switch await interaction.authenticate(request) { - case .cancelled: + case .cancelled, .keyboardInteractive: throw AgentToolError.cancelled case .submit(let answer): if case .passphrase = requirement.mode, @@ -321,9 +321,7 @@ public final class AgentHostResolver: Sendable { { return AuthenticationRequirement(mode: .passphrase(keyID: keyID)) } - if submission.username.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty - || (submission.keyID == nil && submission.password == nil) - { + if submission.username.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { return AuthenticationRequirement(mode: .identity) } if forcedHostID == host.id { @@ -339,7 +337,7 @@ public final class AgentHostResolver: Sendable { return AuthenticationRequirement(mode: .passphrase(keyID: keyID)) } if identity.username.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty - || resolution.credentials.isEmpty + || (identity.hasSecret && resolution.credentials.isEmpty) { return AuthenticationRequirement(mode: .identity) } @@ -585,6 +583,13 @@ public actor AgentSSHConfigurationSession { return await self.authorizeHostKey( challenge, generation: generation ) + }, + keyboardInteractive: { [weak self] challenge in + guard let self, await self.currentGeneration == generation else { throw CancellationError() } + let answers = try await self.interaction.respondToChallenge( + challenge, invocationID: self.invocationID) + guard await self.currentGeneration == generation else { throw CancellationError() } + return answers } ), generation: generation diff --git a/Frameworks/TermindRemoteInfrastructure/SSH/PortForwardingController.swift b/Frameworks/TermindRemoteInfrastructure/SSH/PortForwardingController.swift index b784891b..930347a2 100644 --- a/Frameworks/TermindRemoteInfrastructure/SSH/PortForwardingController.swift +++ b/Frameworks/TermindRemoteInfrastructure/SSH/PortForwardingController.swift @@ -49,6 +49,7 @@ public nonisolated enum PortForwardingRuntimeState: Hashable, Sendable { @Observable public final class PortForwardingController { private let hosts: HostLibraryModel + private let keyboardInteractive: SSHKeyboardInteractiveHandler? private let defaults: UserDefaults? private let enabledDefaultsKey: String public private(set) var states: [PortForwardingRule.ID: PortForwardingRuntimeState] = [:] @@ -69,9 +70,11 @@ public final class PortForwardingController { public init( hosts: HostLibraryModel, defaults: UserDefaults? = nil, - enabledDefaultsKey: String = SettingsKey.portForwardingEnabledRuleIDs + enabledDefaultsKey: String = SettingsKey.portForwardingEnabledRuleIDs, + keyboardInteractive: SSHKeyboardInteractiveHandler? = nil ) { self.hosts = hosts + self.keyboardInteractive = keyboardInteractive self.defaults = defaults self.enabledDefaultsKey = enabledDefaultsKey enabled = Set( @@ -225,7 +228,8 @@ public final class PortForwardingController { states[rule.id] = .connecting let task = Task { let configuration = try hosts.sshConnectionConfiguration(for: host) - try await created.connect(configuration) + try await created.connect( + SSHConnectionRequest(configuration: configuration, keyboardInteractive: keyboardInteractive)) } connectionTasks[host.id] = task do { try await task.value } catch { diff --git a/Frameworks/TermindRemoteInfrastructure/SSH/RemoteConnectionCoordinator.swift b/Frameworks/TermindRemoteInfrastructure/SSH/RemoteConnectionCoordinator.swift index 5c24ee56..01fab923 100644 --- a/Frameworks/TermindRemoteInfrastructure/SSH/RemoteConnectionCoordinator.swift +++ b/Frameworks/TermindRemoteInfrastructure/SSH/RemoteConnectionCoordinator.swift @@ -6,20 +6,32 @@ import TermindDomain /// SSH/SFTP transports. Features receive this service instead of constructing /// infrastructure clients in views. public nonisolated final class RemoteConnectionCoordinator: Sendable { - public init() {} + private let keyboardInteractive: SSHKeyboardInteractiveHandler? + + public init(keyboardInteractive: SSHKeyboardInteractiveHandler? = nil) { + self.keyboardInteractive = keyboardInteractive + } + + public func withKeyboardInteractive(_ handler: @escaping SSHKeyboardInteractiveHandler) + -> RemoteConnectionCoordinator + { + RemoteConnectionCoordinator(keyboardInteractive: handler) + } public func makeSSHSession( output: @escaping SSHSessionActor.OutputHandler, exit: @escaping SSHSessionActor.ExitHandler, shellIntegration: @escaping SSHSessionActor.ShellIntegrationHandler = { _ in } ) -> SSHSessionActor { SSHSessionActor( - output: output, exit: exit, shellIntegration: shellIntegration + output: output, exit: exit, shellIntegration: shellIntegration, keyboardInteractive: keyboardInteractive ) } - public func makeSFTPClient() -> any SFTPClientProtocol { SFTPClient() } + public func makeSFTPClient() -> any SFTPClientProtocol { SFTPClient(keyboardInteractive: keyboardInteractive) } - public func makeExecSession() -> any SSHExecSessionProtocol { SSHExecSession() } + public func makeExecSession() -> any SSHExecSessionProtocol { + SSHExecSession(keyboardInteractive: keyboardInteractive) + } /// Mirrors `connectSFTP`, including its guarantee that a session which fails /// to come up is torn down here rather than handed back half-open. @@ -78,7 +90,8 @@ public nonisolated final class RemoteConnectionCoordinator: Sendable { using configuration: SSHConnectionConfiguration ) async throws { try await SSHSessionActor.installAuthorizedKey( - publicKey, named: keyName, using: configuration + publicKey, named: keyName, + using: SSHConnectionRequest(configuration: configuration, keyboardInteractive: keyboardInteractive) ) } } diff --git a/Frameworks/TermindRemoteInfrastructure/SSH/SFTPClient.swift b/Frameworks/TermindRemoteInfrastructure/SSH/SFTPClient.swift index f5643309..fb2b26dc 100644 --- a/Frameworks/TermindRemoteInfrastructure/SSH/SFTPClient.swift +++ b/Frameworks/TermindRemoteInfrastructure/SSH/SFTPClient.swift @@ -313,7 +313,10 @@ public actor SFTPClient: SFTPClientProtocol { at: path, maximumBytes: maximumBytes, cancellation: cancellation) return NativeSFTPDownloadStream(worker: worker, id: id, identity: identity) } - public init() {} + private let keyboardInteractive: SSHKeyboardInteractiveHandler? + public init(keyboardInteractive: SSHKeyboardInteractiveHandler? = nil) { + self.keyboardInteractive = keyboardInteractive + } private nonisolated let worker = SFTPWorker() public func connect( @@ -321,7 +324,7 @@ public actor SFTPClient: SFTPClientProtocol { progress: SSHRouteProgressHandler? = nil ) async throws { try await worker.connect( - using: SSHConnectionRequest(configuration: configuration), + using: SSHConnectionRequest(configuration: configuration, keyboardInteractive: keyboardInteractive), progress: progress ) } @@ -343,7 +346,7 @@ public actor SFTPClient: SFTPClientProtocol { connectionFailure: (@Sendable (String) -> Void)? ) async throws { try await worker.connect( - using: request, progress: progress, + using: request.withKeyboardInteractiveFallback(keyboardInteractive), progress: progress, connectionFailure: connectionFailure ) } diff --git a/Frameworks/TermindRemoteInfrastructure/SSH/SSHExecSession.swift b/Frameworks/TermindRemoteInfrastructure/SSH/SSHExecSession.swift index 70b63f15..9c6f6ef3 100644 --- a/Frameworks/TermindRemoteInfrastructure/SSH/SSHExecSession.swift +++ b/Frameworks/TermindRemoteInfrastructure/SSH/SSHExecSession.swift @@ -75,7 +75,10 @@ nonisolated extension SSHExecSessionProtocol { public typealias SSHExecOutputHandler = @Sendable (Data, Bool) -> Void public actor SSHExecSession: SSHExecSessionProtocol { - public init() {} + private let keyboardInteractive: SSHKeyboardInteractiveHandler? + public init(keyboardInteractive: SSHKeyboardInteractiveHandler? = nil) { + self.keyboardInteractive = keyboardInteractive + } private nonisolated let worker = SSHExecWorker() public func connect(using configuration: SSHConnectionConfiguration) async throws { @@ -83,7 +86,7 @@ public actor SSHExecSession: SSHExecSessionProtocol { } public func connect(using request: SSHConnectionRequest) async throws { - try await worker.connect(using: request) + try await worker.connect(using: request.withKeyboardInteractiveFallback(keyboardInteractive)) } /// Actor-isolated, so commands on one session queue behind each other. That diff --git a/Frameworks/TermindRemoteInfrastructure/SSH/SSHForwardingSession.swift b/Frameworks/TermindRemoteInfrastructure/SSH/SSHForwardingSession.swift index a826e160..031d23d6 100644 --- a/Frameworks/TermindRemoteInfrastructure/SSH/SSHForwardingSession.swift +++ b/Frameworks/TermindRemoteInfrastructure/SSH/SSHForwardingSession.swift @@ -20,7 +20,11 @@ public actor SSHForwardingSession { } public func connect(_ configuration: SSHConnectionConfiguration) async throws { - try await worker.connect(configuration) + try await connect(SSHConnectionRequest(configuration: configuration)) + } + + public func connect(_ request: SSHConnectionRequest) async throws { + try await worker.connect(request) } public func start(_ rule: PortForwardingRule) async throws { @@ -89,49 +93,22 @@ private nonisolated final class SSHForwardingWorker: @unchecked Sendable { self.failureHandler = failureHandler } - public func connect(_ configuration: SSHConnectionConfiguration) async throws { - try await withCheckedThrowingContinuation { continuation in - queue.async { [self] in - do { - cleanup() - routeTransport = try SSHRoutedSocket.open( - host: configuration.host, port: configuration.port, - proxy: configuration.proxy, jumps: configuration.jumps, timeout: 20 - ) - guard let routeTransport else { throw TermindSSHError.disconnected } - socket = routeTransport.socket - guard let created = libssh2_session_init_ex(nil, nil, nil, nil) else { - throw TermindSSHError.library(String(localized: "Unable to create an SSH forwarding session.")) - } - session = created - libssh2_session_set_blocking(created, 1) - try SSHSessionWorker.performHandshake( - created, socket: socket, - peer: SSHSocketConnector.peerDescription( - socket: socket, - fallbackHost: configuration.host, - port: configuration.port - ) - ) - let observed = try SSHSessionWorker.observation( - host: configuration.host, port: configuration.port, session: created - ) - try SSHSessionWorker.validateHostKey( - observed, expected: configuration.expectedHostKeys - ) - try SSHSessionWorker.authenticate( - username: configuration.username, - credentials: configuration.credentials, session: created - ) - libssh2_session_set_blocking(created, 0) - libssh2_keepalive_config(created, 1, 20) - startTimer() - continuation.resume() - } catch { - cleanup() - continuation.resume(throwing: error) - } + public func connect(_ request: SSHConnectionRequest) async throws { + await queue.perform { self.cleanup() } + let opened = try await SSHSessionWorker.openAuthenticatedSession(using: request, timeout: 20) + do { + await queue.perform { + self.routeTransport = opened.transport + self.socket = opened.transport.socket + self.session = opened.session + libssh2_session_set_blocking(opened.session, 0) + libssh2_keepalive_config(opened.session, 1, 20) + self.startTimer() } + try Task.checkCancellation() + } catch { + await queue.perform { self.cleanup() } + throw error } } diff --git a/Frameworks/TermindRemoteInfrastructure/SSH/SSHKeyboardInteractiveExchange.swift b/Frameworks/TermindRemoteInfrastructure/SSH/SSHKeyboardInteractiveExchange.swift new file mode 100644 index 00000000..163fbed2 --- /dev/null +++ b/Frameworks/TermindRemoteInfrastructure/SSH/SSHKeyboardInteractiveExchange.swift @@ -0,0 +1,194 @@ +import CSSH2 +import Darwin +import Foundation +import TermindApplication + +/// The C callback cannot suspend. Only its dedicated transport queue waits on +/// this condition; the responder runs asynchronously and cancellation wakes it. +public nonisolated final class SSHKeyboardInteractiveExchange: @unchecked Sendable { + private let condition = NSCondition() + private let handler: SSHKeyboardInteractiveHandler? + private var cancelled = false + private var task: Task? + private var result: Result<[String], any Error>? + private var requestID: UUID? + private(set) var error: (any Error)? + private var connectionID = UUID() + private var round = 0 + private var hostID: UUID + private var hostname: String + private var port: Int + private var username: String + + init(configuration: SSHConnectionConfiguration, handler: SSHKeyboardInteractiveHandler?) { + self.handler = handler + hostID = configuration.hostID + hostname = configuration.host + port = configuration.port + username = configuration.username + } + + func setHop(_ hop: SSHRouteHopConfiguration) { + connectionID = UUID() + hostID = hop.id + hostname = hop.host + port = hop.port + username = hop.username + round = 0 + } + + func cancel() { + condition.lock() + cancelled = true + let pending = task + result = .failure(CancellationError()) + condition.broadcast() + condition.unlock() + pending?.cancel() + } + + func checkCancellation() throws { + condition.lock() + let stopped = cancelled + condition.unlock() + if stopped { throw CancellationError() } + } + + func answer(name: String, instruction: String, prompts: [SSHKeyboardInteractiveChallenge.Prompt]) throws -> [String] + { + try checkCancellation() + guard let handler else { + throw TermindSSHError.authenticationFailed( + String(localized: "This server requires interactive authentication.") + ) + } + round += 1 + let challenge = SSHKeyboardInteractiveChallenge( + connectionID: connectionID, hostID: hostID, hostname: hostname, port: port, + username: username, round: round, name: name, instruction: instruction, prompts: prompts + ) + condition.lock() + guard !cancelled else { + condition.unlock() + throw CancellationError() + } + result = nil + requestID = challenge.id + task = Task.detached(priority: .userInitiated) { [weak self] in + let result: Result<[String], any Error> + do { + let answers = try await handler(challenge) + guard answers.count == prompts.count else { + throw TermindSSHError.library(String(localized: "Invalid authentication response.")) + } + result = .success(answers) + } catch { result = .failure(error) } + self?.finish(challenge.id, result: result) + } + while result == nil { condition.wait() } + let answer = result! + result = nil + task = nil + requestID = nil + condition.unlock() + return try answer.get() + } + + private func finish(_ id: UUID, result: Result<[String], any Error>) { + condition.lock() + if requestID == id, !cancelled { + self.result = result + condition.broadcast() + } + condition.unlock() + } + + func recordFailure(_ failure: any Error) { error = failure } +} + +nonisolated func sshKeyboardInteractiveResponse( + _ name: UnsafePointer?, _ nameLength: Int32, + _ instruction: UnsafePointer?, _ instructionLength: Int32, + _ promptCount: Int32, + _ prompts: UnsafePointer?, + _ responses: UnsafeMutablePointer?, + _ abstract: UnsafeMutablePointer? +) { + guard let opaque = abstract?.pointee else { return } + let callback = Unmanaged.fromOpaque(opaque).takeUnretainedValue() + callback.respond( + name: name, nameLength: nameLength, instruction: instruction, instructionLength: instructionLength, + count: promptCount, prompts: prompts, responses: responses + ) +} + +nonisolated final class SSHKeyboardInteractiveCallback { + let exchange: SSHKeyboardInteractiveExchange + let session: OpaquePointer + let socket: Int32 + + init(exchange: SSHKeyboardInteractiveExchange, session: OpaquePointer, socket: Int32) { + self.exchange = exchange + self.session = session + self.socket = socket + } + + private func string(_ pointer: UnsafePointer?, length: Int) -> String { + guard let pointer, length > 0 else { return "" } + return String(decoding: UnsafeRawBufferPointer(start: pointer, count: length), as: UTF8.self) + } + + func respond( + name: UnsafePointer?, nameLength: Int32, + instruction: UnsafePointer?, instructionLength: Int32, + count: Int32, prompts: UnsafePointer?, + responses: UnsafeMutablePointer? + ) { + let started = ContinuousClock.now + defer { + // libssh2's blocking wrapper measures elapsed time across callbacks. + // Extend this call's budget by the time spent waiting for the user. + let elapsed = started.duration(to: .now).components + let timeout = libssh2_session_get_timeout(session) + if timeout > 0 { + libssh2_session_set_timeout( + session, timeout + Int(elapsed.seconds) * 1_000 + Int(elapsed.attoseconds / 1_000_000_000_000_000) + ) + } + } + do { + guard count >= 0, count == 0 || (prompts != nil && responses != nil) else { + throw TermindSSHError.library(String(localized: "Invalid authentication challenge.")) + } + let questions = (0..= 0 { _ = Darwin.shutdown(socket, SHUT_RDWR) } + } + } +} diff --git a/Frameworks/TermindRemoteInfrastructure/SSH/SSHRoutedSocket.swift b/Frameworks/TermindRemoteInfrastructure/SSH/SSHRoutedSocket.swift index cae2b206..d4a9c44b 100644 --- a/Frameworks/TermindRemoteInfrastructure/SSH/SSHRoutedSocket.swift +++ b/Frameworks/TermindRemoteInfrastructure/SSH/SSHRoutedSocket.swift @@ -115,10 +115,16 @@ public nonisolated final class SSHRoutedSocket: @unchecked Sendable { host: String, port: Int, proxy: SSHTransportProxy?, jumps: [SSHRouteHopConfiguration], timeout: TimeInterval, progress: SSHRouteProgressHandler? = nil, - authorizeHostKey: SSHHostKeyAuthorizer? + authorizeHostKey: SSHHostKeyAuthorizer?, + keyboardInteractive: SSHKeyboardInteractiveHandler? = nil ) async throws -> SSHRoutedSocket { let queue = DispatchQueue(label: "com.akinokaede.termind.ssh.route-opening") let state = SSHRouteOpeningState() + let exchange = SSHKeyboardInteractiveExchange( + configuration: SSHConnectionConfiguration( + hostID: UUID(), host: host, port: port, username: "", credentials: [], expectedHostKeys: [] + ), handler: keyboardInteractive + ) return try await withTaskCancellationHandler { do { try await queue.perform { @@ -152,7 +158,7 @@ public nonisolated final class SSHRoutedSocket: @unchecked Sendable { destinationProxy: proxy, jumps: jumps, index: index, timeout: timeout, afterHostKeyApproval: requiredApproval, - progress: progress + progress: progress, keyboardInteractive: exchange ) } } catch is CancellationError { @@ -168,6 +174,7 @@ public nonisolated final class SSHRoutedSocket: @unchecked Sendable { throw error } } onCancel: { + exchange.cancel() state.cancel() queue.async { state.cleanup() } } @@ -300,14 +307,16 @@ private nonisolated final class SSHRouteOpeningState: @unchecked Sendable { destinationProxy: SSHTransportProxy?, jumps: [SSHRouteHopConfiguration], index: Int, timeout: TimeInterval, afterHostKeyApproval: Bool, - progress: SSHRouteProgressHandler? + progress: SSHRouteProgressHandler?, keyboardInteractive: SSHKeyboardInteractiveExchange ) throws { + keyboardInteractive.setHop(hop) guard let session = pendingSession else { throw TermindSSHError.disconnected } progress?(.authenticating(hop.id)) try SSHSessionWorker.authenticate( username: hop.username, credentials: hop.credentials, session: session, - afterHostKeyApproval: afterHostKeyApproval + afterHostKeyApproval: afterHostKeyApproval, + keyboardInteractive: keyboardInteractive, socket: currentSocket ) progress?(.authenticated(hop.id)) diff --git a/Frameworks/TermindRemoteInfrastructure/SSH/SSHSessionActor.swift b/Frameworks/TermindRemoteInfrastructure/SSH/SSHSessionActor.swift index 08b26348..aa5eec03 100644 --- a/Frameworks/TermindRemoteInfrastructure/SSH/SSHSessionActor.swift +++ b/Frameworks/TermindRemoteInfrastructure/SSH/SSHSessionActor.swift @@ -561,11 +561,14 @@ public actor SSHSessionActor { public typealias ShellIntegrationHandler = @Sendable (SSHShellIntegrationEvent) -> Void private let worker: SSHSessionWorker + private let keyboardInteractive: SSHKeyboardInteractiveHandler? public init( output: @escaping OutputHandler, exit: @escaping ExitHandler, - shellIntegration: @escaping ShellIntegrationHandler = { _ in } + shellIntegration: @escaping ShellIntegrationHandler = { _ in }, + keyboardInteractive: SSHKeyboardInteractiveHandler? = nil ) { + self.keyboardInteractive = keyboardInteractive worker = SSHSessionWorker( output: output, exit: exit, shellIntegrationHandler: shellIntegration ) @@ -578,9 +581,14 @@ public actor SSHSessionActor { _ publicKey: String, named keyName: String, using configuration: SSHConnectionConfiguration ) async throws { - try await SSHSessionWorker.installAuthorizedKey( - publicKey, named: keyName, using: configuration - ) + try await installAuthorizedKey( + publicKey, named: keyName, using: SSHConnectionRequest(configuration: configuration)) + } + + public static func installAuthorizedKey( + _ publicKey: String, named keyName: String, using request: SSHConnectionRequest + ) async throws { + try await SSHSessionWorker.installAuthorizedKey(publicKey, named: keyName, using: request) } public func connect( @@ -617,7 +625,7 @@ public actor SSHSessionActor { progress: SSHRouteProgressHandler? = nil ) async throws { try await worker.connect( - using: request, + using: request.withKeyboardInteractiveFallback(keyboardInteractive), shellIntegrationEnabled: shellIntegrationEnabled, columns: columns, rows: rows, progress: progress ) @@ -738,9 +746,12 @@ public nonisolated final class SSHSessionWorker: @unchecked Sendable { host: configuration.host, port: configuration.port, proxy: configuration.proxy, jumps: configuration.jumps, timeout: timeout, progress: progress, - authorizeHostKey: request.authorizeHostKey + authorizeHostKey: request.authorizeHostKey, + keyboardInteractive: request.keyboardInteractive ) let queue = DispatchQueue(label: "com.akinokaede.termind.ssh.authenticated-opening") + let exchange = SSHKeyboardInteractiveExchange( + configuration: configuration, handler: request.keyboardInteractive) let state = SSHAuthenticatedOpeningState(transport: transport) return try await withTaskCancellationHandler { do { @@ -761,7 +772,7 @@ public nonisolated final class SSHSessionWorker: @unchecked Sendable { try state.authenticate( configuration: configuration, afterHostKeyApproval: requiredApproval, - progress: progress + progress: progress, keyboardInteractive: exchange ) } return try await queue.perform { try state.takeResult() } @@ -770,6 +781,7 @@ public nonisolated final class SSHSessionWorker: @unchecked Sendable { throw error } } onCancel: { + exchange.cancel() state.cancel() queue.async { state.cleanup() } } @@ -777,22 +789,19 @@ public nonisolated final class SSHSessionWorker: @unchecked Sendable { public static func installAuthorizedKey( _ publicKey: String, named keyName: String, - using configuration: SSHConnectionConfiguration + using request: SSHConnectionRequest ) async throws { _ = initialization let input = try authorizedKeyInstallInput(publicKey: publicKey, keyName: keyName) - - try await withCheckedThrowingContinuation { continuation in - DispatchQueue.global(qos: .userInitiated).async { - do { - try installAuthorizedKeyBlocking( - input, using: configuration - ) - continuation.resume() - } catch { - continuation.resume(throwing: error) - } + let opened = try await openAuthenticatedSession(using: request, timeout: 20) + let queue = DispatchQueue(label: "com.akinokaede.termind.ssh.key-export") + let interruption = SSHKeyExportInterruption(socket: opened.transport.socket) + try await withTaskCancellationHandler { + try await queue.perform { + try installAuthorizedKeyBlocking(input, opened: opened, interruption: interruption) } + } onCancel: { + interruption.cancel() } } @@ -800,11 +809,10 @@ public nonisolated final class SSHSessionWorker: @unchecked Sendable { /// the OpenSSH directory with restrictive permissions and performs an exact /// line check before appending, making repeated exports idempotent. private static func installAuthorizedKeyBlocking( - _ input: Data, using configuration: SSHConnectionConfiguration + _ input: Data, opened: SSHOpenedSession, interruption: SSHKeyExportInterruption ) throws { - let (transport, created) = try openAuthenticatedSession( - using: configuration, timeout: 20 - ) + let transport = opened.transport + let created = opened.session var channel: OpaquePointer? defer { if let channel { @@ -816,6 +824,7 @@ public nonisolated final class SSHSessionWorker: @unchecked Sendable { created, SSH_DISCONNECT_BY_APPLICATION, "Termind completed key export", "" ) _ = libssh2_session_free(created) + interruption.finish() transport.close() } @@ -1000,17 +1009,45 @@ public nonisolated final class SSHSessionWorker: @unchecked Sendable { } public static func authenticate( - username: String, credentials: [SSHCredentials], session: OpaquePointer + username: String, credentials: [SSHCredentials], session: OpaquePointer, + keyboardInteractive: SSHKeyboardInteractiveExchange? = nil, socket: Int32 = -1 ) throws { - let methods = try authenticationMethods(username: username, session: session) + var methods = try authenticationMethods(username: username, session: session) // Asking for the method list sends SSH's `none` authentication request. // Servers that need no credential accept it there and report the // session as authenticated without another userauth call. if libssh2_userauth_authenticated(session) == 1 { return } var attempted = false var secureEnclaveError: (any Error)? - for credentials in credentials { - switch credentials { + var remaining = credentials + var attemptedInteractive = false + while true { + guard + let index = remaining.firstIndex(where: { credential in + switch credential { + case .password: methods.contains("password") + case .privateKey, .secureEnclave: methods.contains("publickey") + } + }) + else { + guard !attemptedInteractive, methods.contains("keyboard-interactive"), let keyboardInteractive else { + break + } + attempted = true + attemptedInteractive = true + let result = Self.keyboardInteractive( + username: username, exchange: keyboardInteractive, session: session, socket: socket + ) + try keyboardInteractive.checkCancellation() + if let error = keyboardInteractive.error { throw error } + if result == 0 { return } + methods = try authenticationMethods(username: username, session: session) + if libssh2_userauth_authenticated(session) == 1 { return } + continue + } + try keyboardInteractive?.checkCancellation() + let credential = remaining.remove(at: index) + switch credential { case .password(let password): if methods.contains("password") { attempted = true @@ -1024,14 +1061,6 @@ public nonisolated final class SSHSessionWorker: @unchecked Sendable { } if result == 0 { return } } - if methods.contains("keyboard-interactive") { - attempted = true - if keyboardInteractive( - username: username, password: password, session: session - ) == 0 { - return - } - } case .privateKey(let privateKey, let publicKey, let passphrase): guard methods.contains("publickey") else { continue } attempted = true @@ -1083,6 +1112,8 @@ public nonisolated final class SSHSessionWorker: @unchecked Sendable { if result == 0 { return } secureEnclaveError = signer.error } + methods = try authenticationMethods(username: username, session: session) + if libssh2_userauth_authenticated(session) == 1 { return } } if let secureEnclaveError { throw TermindSSHError.authenticationFailed(secureEnclaveError.localizedDescription) @@ -1099,14 +1130,16 @@ public nonisolated final class SSHSessionWorker: @unchecked Sendable { public static func authenticate( username: String, credentials: [SSHCredentials], - session: OpaquePointer, afterHostKeyApproval: Bool + session: OpaquePointer, afterHostKeyApproval: Bool, + keyboardInteractive: SSHKeyboardInteractiveExchange? = nil, socket: Int32 = -1 ) throws { do { try authenticate( username: username, credentials: credentials, - session: session + session: session, keyboardInteractive: keyboardInteractive, socket: socket ) } catch { + if error is CancellationError || keyboardInteractive?.error != nil { throw error } let code = libssh2_session_last_errno(session) if afterHostKeyApproval, code == LIBSSH2_ERROR_SOCKET_DISCONNECT @@ -1143,13 +1176,17 @@ public nonisolated final class SSHSessionWorker: @unchecked Sendable { } private static func keyboardInteractive( - username: String, password: String, session: OpaquePointer + username: String, exchange: SSHKeyboardInteractiveExchange, session: OpaquePointer, socket: Int32 ) -> Int32 { - let box = Unmanaged.passRetained(SSHKeyboardPassword(password)) + let callback = SSHKeyboardInteractiveCallback(exchange: exchange, session: session, socket: socket) + let box = Unmanaged.passRetained(callback) let abstract = libssh2_session_abstract(session) + let previous = abstract?.pointee + let timeout = libssh2_session_get_timeout(session) abstract?.pointee = box.toOpaque() defer { - abstract?.pointee = nil + abstract?.pointee = previous + libssh2_session_set_timeout(session, timeout) box.release() } return username.withCString { user in @@ -1495,7 +1532,7 @@ private nonisolated final class SSHAuthenticatedOpeningState: @unchecked Sendabl func authenticate( configuration: SSHConnectionConfiguration, afterHostKeyApproval: Bool, - progress: SSHRouteProgressHandler? + progress: SSHRouteProgressHandler?, keyboardInteractive: SSHKeyboardInteractiveExchange ) throws { guard let session else { throw TermindSSHError.disconnected } progress?(.authenticating(configuration.hostID)) @@ -1503,7 +1540,8 @@ private nonisolated final class SSHAuthenticatedOpeningState: @unchecked Sendabl username: configuration.username, credentials: configuration.credentials, session: session, - afterHostKeyApproval: afterHostKeyApproval + afterHostKeyApproval: afterHostKeyApproval, + keyboardInteractive: keyboardInteractive, socket: transport?.socket ?? -1 ) progress?(.authenticated(configuration.hostID)) } @@ -1574,27 +1612,19 @@ private nonisolated let secureEnclaveSignCallback: } } -private nonisolated final class SSHKeyboardPassword: @unchecked Sendable { - public let value: String +/// Stops cancellation from shutting down a descriptor after it has been closed +/// and potentially reused by another operation. +private nonisolated final class SSHKeyExportInterruption: @unchecked Sendable { + private let lock = NSLock() + private var socket: Int32 - public init(_ value: String) { - self.value = value - } -} + init(socket: Int32) { self.socket = socket } -private nonisolated func sshKeyboardInteractiveResponse( - _ name: UnsafePointer?, _ nameLength: Int32, - _ instruction: UnsafePointer?, _ instructionLength: Int32, - _ promptCount: Int32, - _ prompts: UnsafePointer?, - _ responses: UnsafeMutablePointer?, - _ abstract: UnsafeMutablePointer? -) { - guard promptCount > 0, let responses, let opaque = abstract?.pointee else { return } - let password = Unmanaged.fromOpaque(opaque) - .takeUnretainedValue().value - for index in 0..= 0 { _ = Darwin.shutdown(socket, SHUT_RDWR) } + } } + + func finish() { lock.withLock { socket = -1 } } } diff --git a/Frameworks/TermindRemoteInfrastructure/Transfers/SFTPTransferCoordinator.swift b/Frameworks/TermindRemoteInfrastructure/Transfers/SFTPTransferCoordinator.swift index 03e65003..59d5858a 100644 --- a/Frameworks/TermindRemoteInfrastructure/Transfers/SFTPTransferCoordinator.swift +++ b/Frameworks/TermindRemoteInfrastructure/Transfers/SFTPTransferCoordinator.swift @@ -396,7 +396,7 @@ public final class SFTPTransferCoordinator { do { if let id = runtime.connection.borrowedID { try await reserveBorrowedConnection(id) } defer { if let id = runtime.connection.borrowedID { releaseBorrowedConnections([id]) } } - let lease = try await clientLease(for: runtime.connection) + let lease = try await clientLease(for: runtime.connection, allowsAuthentication: false) do { try await runtime.resume.cleanup(using: lease.client) } catch { await lease.disconnect() throw error @@ -489,7 +489,7 @@ public final class SFTPTransferCoordinator { var lease: ClientLease? var result: SFTPTransfer.State do { - let acquired = try await clientLease(for: runtime.connection) + let acquired = try await clientLease(for: runtime.connection, cancellation: runtime.cancellation) lease = acquired let client = acquired.client let progress: @Sendable (SFTPProgress) -> Void = { [weak self] value in @@ -579,10 +579,11 @@ public final class SFTPTransferCoordinator { var destinationLease: ClientLease? let result: SFTPTransfer.State do { - let acquiredSource = try await clientLease(for: runtime.sourceConnection) + let acquiredSource = try await clientLease( + for: runtime.sourceConnection, cancellation: runtime.cancellation) sourceLease = acquiredSource let acquiredDestination = try await clientLease( - for: runtime.destinationConnection + for: runtime.destinationConnection, cancellation: runtime.cancellation ) destinationLease = acquiredDestination let progress: @Sendable (SFTPProgress) -> Void = { [weak self] value in @@ -629,13 +630,32 @@ public final class SFTPTransferCoordinator { } private func clientLease( - for connection: SFTPTransferConnection + for connection: SFTPTransferConnection, cancellation: TransferCancellationToken? = nil, + allowsAuthentication: Bool = true ) async throws -> ClientLease { switch connection { case .saved(let configuration): let client = clientFactory() do { - try await client.connect(using: configuration, progress: nil) + var request = SSHConnectionRequest(configuration: configuration) + if !allowsAuthentication { + // Cleanup after cancellation must not start another user interaction. + // If fresh MFA is required, retain the existing cleanup-failure report. + request.keyboardInteractive = { _ in throw CancellationError() } + } + let openingRequest = request + let opening = Task { + try await client.connect(using: openingRequest, progress: nil, connectionFailure: nil) + } + let observer = cancellation?.observeCancellation { opening.cancel() } + defer { if let observer { cancellation?.removeObserver(observer) } } + try await withTaskCancellationHandler { + try await opening.value + try Task.checkCancellation() + if cancellation?.isCancelled == true { throw CancellationError() } + } onCancel: { + opening.cancel() + } } catch { await client.disconnect() throw error diff --git a/Memory/ARCHITECTURE.md b/Memory/ARCHITECTURE.md index 62a51738..a2041878 100644 --- a/Memory/ARCHITECTURE.md +++ b/Memory/ARCHITECTURE.md @@ -142,6 +142,27 @@ authentication; callers never perform a separate discovery connection. Generation tokens prevent callbacks, credential results, and retry timers from a retired connection mutating its replacement. +SSH keyboard-interactive challenges use the same authenticated-opening path for +PTY, exec, SFTP, forwarding, key installation, and each jump host. The C callback +waits only on its transport queue while an async, cancellable application responder +presents the server's literal name, instructions, prompts and echo flags. Rounds and +connections have distinct request IDs; answers are ephemeral and never autofilled +from saved passwords, cached, logged, or added to Agent/MCP history. Empty answers +are valid; zero-question rounds complete without requiring a visible form. User +waiting time does not consume libssh2's network timeout. Cancellation wakes the +callback, cancels its responder, and interrupts the pending transport. + +User connections own their challenge queues. Agent runs reuse their application +interaction queue; macOS background operations and MCP share the native authentication +window with independently cancellable origins. Stopping MCP preserves app-owned +requests. Mobile background operations use an app-owned queue presented by one active +scene. Cancelling a transfer interrupts pending authentication; subsequent temporary-file +cleanup may use noninteractive credentials but must not reopen an MFA prompt. A cleanup +that requires new interaction retains the existing temporary-file cleanup failure report. Forwarding rules sharing a host session share authentication; a new connection +must answer fresh challenges. An Identity may contain only a username: preparation +permits an empty credential list, SSH tries `none`, then advertised methods. Missing +configured secrets and existing authentication failure/retry behavior stay unchanged. + An Identity's device-local `lastUsedAt` means its saved password or private key was successfully read for connection setup, not that remote authentication succeeded. Connection-specific resolvers batch those accesses across destinations, jump hosts, diff --git a/Scripts/ssh-authentication-fixture.py b/Scripts/ssh-authentication-fixture.py new file mode 100644 index 00000000..bfb9d426 --- /dev/null +++ b/Scripts/ssh-authentication-fixture.py @@ -0,0 +1,193 @@ +#!/usr/bin/env python3 +"""Loopback-only SSH authentication fixture; never executes client commands. + +Run with a temporary virtual environment containing paramiko==4.0.0: + python Scripts/ssh-authentication-fixture.py --metadata /tmp/ssh-fixture.json +Pass that path to tests as TEST_RUNNER_TERMIND_SSH_FIXTURE. Keys are generated +in memory; the metadata file is removed when this process exits. +""" +import argparse +import base64 +import hashlib +import io +import json +import os +import select +import signal +import socket +import threading + +import paramiko + + +class Files(paramiko.SFTPServerInterface): + def canonicalize(self, path): + return "/" + + def list_folder(self, path): + item = paramiko.SFTPAttributes() + item.filename = "fixture.txt" + item.st_mode = 0o100644 + item.st_size = 8 + return [item] + + +class Authentication(paramiko.ServerInterface): + def __init__(self, client_key): + self.client_key = client_key + self.user = "" + self.factor = False + self.round = 0 + self.destinations = {} + self.shells = set() + + def get_allowed_auths(self, username): + if self.factor: + return "password" if username == "kbd-password" else "keyboard-interactive" + return { + "password": "password", "password-mfa": "password", + "publickey-mfa": "publickey", "reject": "password", + }.get(username, "keyboard-interactive") + + def check_auth_none(self, username): + self.user = username + return paramiko.AUTH_SUCCESSFUL if username == "none" else paramiko.AUTH_FAILED + + def check_auth_password(self, username, password): + if password != "fixture-password": + return paramiko.AUTH_FAILED + if username == "password-mfa": + self.factor = True + return paramiko.AUTH_PARTIALLY_SUCCESSFUL + accepted = username == "password" or (username == "kbd-password" and self.factor) + return paramiko.AUTH_SUCCESSFUL if accepted else paramiko.AUTH_FAILED + + def check_auth_publickey(self, username, key): + if username == "publickey-mfa" and key == self.client_key: + self.factor = True + return paramiko.AUTH_PARTIALLY_SUCCESSFUL + return paramiko.AUTH_FAILED + + def check_auth_interactive(self, username, submethods): + self.round = 1 + if username in ("password-mfa", "publickey-mfa") and not self.factor: + return paramiko.AUTH_FAILED + if username == "push": + return paramiko.InteractiveQuery("PAM", "Approve this login on your device.") + if username == "multi": + return paramiko.InteractiveQuery("PAM 登录", "Two questions", ("用户名", True), ("OTP", False)) + return paramiko.InteractiveQuery("OTP", "Enter the current code.", ("OTP", False)) + + def check_auth_interactive_response(self, responses): + if self.user == "kbd-password" and responses == ["123456"]: + self.factor = True + return paramiko.AUTH_PARTIALLY_SUCCESSFUL + if self.user == "multi": + if self.round == 1 and responses == ["部署 user", "123456"]: + self.round += 1 + return paramiko.InteractiveQuery("PAM", "Empty answers are valid.", ("Optional", False)) + return paramiko.AUTH_SUCCESSFUL if self.round == 2 and responses == [""] else paramiko.AUTH_FAILED + if self.user == "push": + return paramiko.AUTH_SUCCESSFUL if responses == [] else paramiko.AUTH_FAILED + return paramiko.AUTH_SUCCESSFUL if responses == ["123456"] else paramiko.AUTH_FAILED + + def check_channel_request(self, kind, chanid): + return paramiko.OPEN_SUCCEEDED if kind == "session" else paramiko.OPEN_FAILED_ADMINISTRATIVELY_PROHIBITED + + def check_channel_exec_request(self, channel, command): + def reply(): + channel.sendall(b"fixture\n") + channel.send_exit_status(0) + channel.shutdown_write() + threading.Thread(target=reply, daemon=True).start() + return True + + def check_channel_pty_request(self, *args): + return True + + def check_channel_shell_request(self, channel): + self.shells.add(channel.get_id()) + return True + + def check_channel_direct_tcpip_request(self, chanid, origin, destination): + # Allow routing only back to this fixture, never to other services. + if destination != ("127.0.0.1", self.port): + return paramiko.OPEN_FAILED_ADMINISTRATIVELY_PROHIBITED + self.destinations[chanid] = destination + return paramiko.OPEN_SUCCEEDED + + +def relay(channel, destination): + with socket.create_connection(destination) as peer: + try: + while True: + ready, _, _ = select.select([channel, peer], [], [], 1) + for source in ready: + data = source.recv(32768) + if not data: + return + (peer if source is channel else channel).sendall(data) + finally: + channel.close() + + +def serve(sock, host_key, client_key, port): + transport = paramiko.Transport(sock) + transport.banner_timeout = 120 + transport.auth_timeout = 120 + transport.add_server_key(host_key) + transport.set_subsystem_handler("sftp", paramiko.SFTPServer, Files) + server = Authentication(client_key) + server.port = port + channels = [] + try: + transport.start_server(server=server) + while transport.is_active(): + channel = transport.accept(0.2) + if channel: + channels.append(channel) + destination = server.destinations.get(channel.get_id()) + if destination: + threading.Thread(target=relay, args=(channel, destination), daemon=True).start() + except (EOFError, OSError, paramiko.SSHException): + pass + finally: + transport.close() + + +def main(): + parser = argparse.ArgumentParser() + parser.add_argument("--metadata", required=True) + args = parser.parse_args() + signal.signal(signal.SIGTERM, lambda *_: (_ for _ in ()).throw(KeyboardInterrupt())) + host_key = paramiko.RSAKey.generate(2048) + client_key = paramiko.RSAKey.generate(2048) + private = io.StringIO() + client_key.write_private_key(private) + with socket.socket() as listener: + listener.bind(("127.0.0.1", 0)) + listener.listen() + port = listener.getsockname()[1] + fingerprint = base64.b64encode(hashlib.sha256(host_key.asbytes()).digest()).decode().rstrip("=") + metadata = { + "port": port, + "privateKey": private.getvalue(), + "hostFingerprint": "SHA256:" + fingerprint, + } + # Ephemeral test material only, never a user credential. + fd = os.open(args.metadata, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600) + with os.fdopen(fd, "w") as output: + json.dump(metadata, output) + print(f"SSH fixture listening on 127.0.0.1:{port}", flush=True) + try: + while True: + sock, _ = listener.accept() + threading.Thread(target=serve, args=(sock, host_key, client_key, port), daemon=True).start() + except KeyboardInterrupt: + pass + finally: + os.unlink(args.metadata) + + +if __name__ == "__main__": + main() diff --git a/TFI/App/MobileAppModel.swift b/TFI/App/MobileAppModel.swift index 8be51bda..0e12bfa2 100644 --- a/TFI/App/MobileAppModel.swift +++ b/TFI/App/MobileAppModel.swift @@ -66,6 +66,11 @@ final class MobileAppModel { @ObservationIgnored private var workspaceActivitySessions: [WorkspaceActivityAttributes.Session] = [] private let defaults: UserDefaults? private let snippetTimestampFallback: Date + let keyboardAuthentication: SSHKeyboardInteractiveQueue + var keyboardAuthenticationSceneID: UUID? { + scenePhases.filter { $0.value == .active }.keys.sorted { $0.uuidString < $1.uuidString }.first + } + private let background: any MobileBackgroundTaskManaging private let workspaceConnectionServices: MobileWorkspaceConnectionServices private let workspaceStore: MobileWorkspaceStore @@ -100,7 +105,10 @@ final class MobileAppModel { let inMemory = dependencies.inMemory let defaults = dependencies.defaults let background = dependencies.background - let workspaceConnectionServices = dependencies.workspaceConnections + let keyboardAuthentication = SSHKeyboardInteractiveQueue() + self.keyboardAuthentication = keyboardAuthentication + let workspaceConnectionServices = dependencies.workspaceConnections.withKeyboardInteractive( + keyboardAuthentication.handler) self.background = background self.workspaceConnectionServices = workspaceConnectionServices workspaceStore = @@ -164,7 +172,9 @@ final class MobileAppModel { memory = MemoryModel( storage: GRDBMemoryStore(directory: GRDBMemoryStore.defaultDirectory), defaults: settingsDefaults) library = HostLibraryModel(persistence: persistence, secrets: secrets) - metrics = MobileMetricsModel(preferences: metricsPersistence, hosts: library, services: dependencies.metrics) + metrics = MobileMetricsModel( + preferences: metricsPersistence, hosts: library, services: dependencies.metrics, + keyboardInteractive: keyboardAuthentication.handler) let speechAudio = IOSSpeechAudioInput() let speech = SpeechTranscriptionCoordinator( service: AppleSpeechTranscriptionService(audioInput: speechAudio) @@ -184,8 +194,11 @@ final class MobileAppModel { snippets = SnippetLibraryModel(library: library) knownHosts = KnownHostsLibraryModel(library: library) forwardingLibrary = ForwardingLibraryModel(library: library) - forwardings = PortForwardingController(hosts: library, defaults: dependencies.forwardingDefaults) - transfers = SFTPTransferCoordinator(store: library, clientFactory: { SFTPClient() }) + forwardings = PortForwardingController( + hosts: library, defaults: dependencies.forwardingDefaults, + keyboardInteractive: keyboardAuthentication.handler) + transfers = SFTPTransferCoordinator( + store: library, clientFactory: { SFTPClient(keyboardInteractive: keyboardAuthentication.handler) }) sftpEdits = SFTPEditService(transfers: transfers, host: { [weak library] in library?.host($0) }) library.willDeleteHost = { [weak sftpEdits] hostID, _ in sftpEdits?.invalidate(hostID: hostID) } library.willDeletePortForwardings = { [weak forwardings] ids in forwardings?.suspend(ids) } @@ -205,6 +218,7 @@ final class MobileAppModel { guard let self else { return } sftpEdits.invalidate(hostID: payload.hostID) transfers.cancelAll(for: payload.hostID) + keyboardAuthentication.cancelAll(hostID: payload.hostID) }, stopForwardings: { [weak forwardings] payload in forwardings?.remove(payload.forwardingRuleIDs) @@ -296,6 +310,7 @@ final class MobileAppModel { for workspace in workspaces.filter({ $0.descriptor.hostID == id }) { close(workspace.id) } sftpEdits.invalidate(hostID: id) transfers.cancelAll(for: id) + keyboardAuthentication.cancelAll(hostID: id) forwardings.remove(library.portForwardings(for: id).map(\.id)) metrics.remove(id) try hosts.requestHostDeletion(id) @@ -331,6 +346,7 @@ final class MobileAppModel { for workspace in workspaces { workspace.suspendForBackgroundExpiration() } forwardings.suspendAll() transfers.pauseAll() + keyboardAuthentication.cancelAll() assistant.expireBackground() } liveActivities.update(workspaceActivitySessions) @@ -385,6 +401,7 @@ final class MobileAppModel { if change.isRemoved { close(workspace.id) } else { workspace.disconnect() } } transfers.cancelAll(for: change.id) + keyboardAuthentication.cancelAll(hostID: change.id) forwardings.suspend(library.portForwardings(for: change.id).map(\.id)) } if change.isRemoved { diff --git a/TFI/App/MobileAppReviewPresenter.swift b/TFI/App/MobileAppReviewPresenter.swift index 8c958b4b..130d9a53 100644 --- a/TFI/App/MobileAppReviewPresenter.swift +++ b/TFI/App/MobileAppReviewPresenter.swift @@ -26,6 +26,7 @@ struct MobileAppReviewPresenter: ViewModifier { || scene.renamingWorkspace != nil || scene.isWorkspaceUnavailable || app.error != nil + || app.keyboardAuthentication.current != nil || app.pro.isPaywallPresented || app.pro.isProcessing, request: { requestReview() } diff --git a/TFI/App/MobileRootView.swift b/TFI/App/MobileRootView.swift index ef6be6ad..4a35a1a4 100644 --- a/TFI/App/MobileRootView.swift +++ b/TFI/App/MobileRootView.swift @@ -61,6 +61,23 @@ struct MobileRootView: View { .sheet(isPresented: Binding(get: { app.pro.isPaywallPresented }, set: { app.pro.isPaywallPresented = $0 })) { MobileProView(pro: app.pro) } + .sheet( + isPresented: Binding( + get: { app.keyboardAuthentication.current != nil && app.keyboardAuthenticationSceneID == scene.id }, + set: { _ in } + ) + ) { + if let challenge = app.keyboardAuthentication.current { + SSHKeyboardInteractiveForm( + challenge: challenge, + submit: { app.keyboardAuthentication.respond(challenge.id, answers: $0) }, + cancel: { app.keyboardAuthentication.cancel(challenge.id) } + ) + .id(challenge.id) + .padding(24) + .interactiveDismissDisabled() + } + } .sheet(item: $scene.renamingWorkspace) { workspace in MobileWorkspaceRenameView(workspace: workspace) { title in app.renameWorkspace(workspace.id, to: title) diff --git a/TFI/Application/Connections/MobileWorkspaceConnectionServices.swift b/TFI/Application/Connections/MobileWorkspaceConnectionServices.swift index 5571264a..1e2680e3 100644 --- a/TFI/Application/Connections/MobileWorkspaceConnectionServices.swift +++ b/TFI/Application/Connections/MobileWorkspaceConnectionServices.swift @@ -23,4 +23,17 @@ struct MobileWorkspaceConnectionServices { (@Sendable (String) -> Void)? ) async throws -> any SFTPClientProtocol var writeSSH: (SSHSessionActor, Data) async -> Void + + func withKeyboardInteractive(_ handler: @escaping SSHKeyboardInteractiveHandler) -> Self { + var services = self + services.connectSSH = { session, request, integration, columns, rows, progress in + try await self.connectSSH( + session, request.withKeyboardInteractiveFallback(handler), integration, columns, rows, progress + ) + } + services.connectSFTP = { request, progress, failure in + try await self.connectSFTP(request.withKeyboardInteractiveFallback(handler), progress, failure) + } + return services + } } diff --git a/TFI/Application/Metrics/MobileMetricsModel.swift b/TFI/Application/Metrics/MobileMetricsModel.swift index ee473111..1b87e68a 100644 --- a/TFI/Application/Metrics/MobileMetricsModel.swift +++ b/TFI/Application/Metrics/MobileMetricsModel.swift @@ -16,14 +16,15 @@ final class MobileMetricsModel { init( preferences: any MetricsConfigurationStoring, hosts: any RemoteSessionHostStore, - services: MobileMetricsServices + services: MobileMetricsServices, + keyboardInteractive: SSHKeyboardInteractiveHandler? = nil ) { self.hosts = hosts let limit = limit core = ServerMetricsModel( hosts: hosts, preferences: preferences, makeSampler: services.makeSampler, - cardLimit: { limit.maximum }) + cardLimit: { limit.maximum }, keyboardInteractive: keyboardInteractive) } func add(_ id: UUID, pro: Bool) -> Bool { diff --git a/TFI/Features/AI/MobileAssistantInteraction.swift b/TFI/Features/AI/MobileAssistantInteraction.swift index ec8f938a..3d55c820 100644 --- a/TFI/Features/AI/MobileAssistantInteraction.swift +++ b/TFI/Features/AI/MobileAssistantInteraction.swift @@ -504,9 +504,14 @@ private struct MobileAssistantAuthenticationForm: View { .submit( .init( identityID: identity, identityName: nil, - username: username, password: password, passphrase: passphrase, keyID: key, save: save))) - }, cancel: { conversation.respondToSSHAuthentication(.cancelled) }, usesContainerChrome: false, - library: hosts + username: username, password: password, passphrase: passphrase, keyID: key, save: save)), + requestID: request.id) + }, cancel: { conversation.respondToSSHAuthentication(.cancelled, requestID: request.id) }, + usesContainerChrome: false, + library: hosts, + respondKeyboardInteractive: { + conversation.respondToSSHAuthentication(.keyboardInteractive($0), requestID: request.id) + } ) .id(request.id) } diff --git a/TFI/Features/Workspaces/MobileWorkspace.swift b/TFI/Features/Workspaces/MobileWorkspace.swift index 143cdaa5..e2fc3c8b 100644 --- a/TFI/Features/Workspaces/MobileWorkspace.swift +++ b/TFI/Features/Workspaces/MobileWorkspace.swift @@ -131,6 +131,7 @@ final class MobileWorkspace: Identifiable { private var generation = UUID() private let hosts: any RemoteSessionHostStore private let connectionServices: MobileWorkspaceConnectionServices + var keyboardAuthentication: SSHKeyboardInteractiveQueue { connection.keyboardAuthentication } private let connection: RemoteSessionConnectionController private var viewport = InMemoryTerminalViewport(columns: 80, rows: 24) @@ -572,6 +573,10 @@ final class MobileWorkspace: Identifiable { return await self.connection.authorizeHostKey( challenge, using: hosts ) + }, + keyboardInteractive: { [weak self] challenge in + guard let self, await self.generation == token else { throw CancellationError() } + return try await self.connection.answerKeyboardInteractive(challenge) } ) diff --git a/TFI/Features/Workspaces/MobileWorkspaceView.swift b/TFI/Features/Workspaces/MobileWorkspaceView.swift index 25493b84..2a76358b 100644 --- a/TFI/Features/Workspaces/MobileWorkspaceView.swift +++ b/TFI/Features/Workspaces/MobileWorkspaceView.swift @@ -692,7 +692,8 @@ struct MobileWorkspaceView: View { passphrase: passphrase, keyID: key, save: save) }, accept: { workspace.trustHostKey(remember: $0) }, retry: { workspace.connect() }, retryReconnectNow: { workspace.retryReconnectNow() }, - stopReconnecting: { workspace.stopReconnecting() }, cancel: { closeWorkspace(workspace.id) }) + stopReconnecting: { workspace.stopReconnecting() }, cancel: { closeWorkspace(workspace.id) }, + keyboardAuthentication: workspace.keyboardAuthentication) } } diff --git a/TFI/UI/Components/MobileConnectionJourneyView.swift b/TFI/UI/Components/MobileConnectionJourneyView.swift index 861bc8ac..7e71bf5b 100644 --- a/TFI/UI/Components/MobileConnectionJourneyView.swift +++ b/TFI/UI/Components/MobileConnectionJourneyView.swift @@ -15,6 +15,7 @@ struct MobileConnectionJourneyView: View { let retryReconnectNow: () -> Void let stopReconnecting: () -> Void let cancel: () -> Void + var keyboardAuthentication: SSHKeyboardInteractiveQueue? = nil @Environment(\.accessibilityReduceMotion) private var reduceMotion @@ -47,6 +48,12 @@ struct MobileConnectionJourneyView: View { private var content: some View { if journey.showsLogs { logs + } else if let queue = keyboardAuthentication, let challenge = queue.current { + SSHKeyboardInteractiveForm( + challenge: challenge, submit: { queue.respond(challenge.id, answers: $0) }, + cancel: { queue.cancel(challenge.id) }, usesContainerChrome: true + ) + .id(challenge.id) } else if let authenticationPrompt { SSHAuthenticationForm( prompt: authenticationPrompt, submit: submit, cancel: cancel, library: library diff --git a/TFITests/MobileConnectionJourneyRenderingTests.swift b/TFITests/MobileConnectionJourneyRenderingTests.swift index dc4e8133..d62bc6f4 100644 --- a/TFITests/MobileConnectionJourneyRenderingTests.swift +++ b/TFITests/MobileConnectionJourneyRenderingTests.swift @@ -1,6 +1,7 @@ import SwiftUI import TermindApplication import TermindDomain +import TermindPresentation import Testing import UIKit @@ -9,6 +10,33 @@ import UIKit /// Opt-in native screenshots cover the connection states without opening a transport. @Suite(.serialized) @MainActor struct MobileConnectionJourneyRenderingTests { + @Test(arguments: ["en", "zh-Hans", "zh-Hant"]) + func rendersInteractiveAuthentication(_ language: String) async throws { + let host = TermindDomain.Host(name: "Production Gateway", host: "gateway.example.invalid") + let library = HostLibraryModel(hosts: [host]) + let journey = ConnectionJourney() + journey.begin(host: host, route: [host], purpose: .sshSession, username: "deploy") + journey.waitForAuthentication(at: host.id) + let queue = SSHKeyboardInteractiveQueue() + let challenge = SSHKeyboardInteractiveChallenge( + connectionID: UUID(), hostID: host.id, hostname: host.host, port: 22, + username: "deploy", round: 1, name: "PAM", instruction: "Enter your account and verification code.", + prompts: [.init(text: "Account", echo: true), .init(text: "Verification code", echo: false)]) + let request = Task { try await queue.request(challenge) } + defer { + queue.cancel(challenge.id) + request.cancel() + } + for _ in 0..<100 where queue.current == nil { await Task.yield() } + _ = try #require(queue.current) + try await capture( + "keyboard-interactive", language: language, scheme: .light, + view: MobileConnectionJourneyView( + journey: journey, library: library, authenticationPrompt: nil, hostKey: nil, + reconnectState: nil, submit: { _, _, _, _, _, _ in }, accept: { _ in }, retry: {}, + retryReconnectNow: {}, stopReconnecting: {}, cancel: {}, keyboardAuthentication: queue)) + } + @Test func renderConnectionJourneyStates() async throws { guard ProcessInfo.processInfo.environment["TERMIND_JOURNEY_SCREENSHOTS"] == "1" else { return } let host = TermindDomain.Host(name: "Production Gateway", host: "gateway.example.invalid") @@ -83,6 +111,7 @@ struct MobileConnectionJourneyRenderingTests { } let data = try #require(image.pngData()) #expect(data.count > 10_000) + Attachment.record(data, named: "\(name)-\(language)-\(scheme == .dark ? "dark" : "light").png") let directory = FileManager.default.temporaryDirectory.appending(path: "journey-screenshots") try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) let device = UIDevice.current.userInterfaceIdiom == .pad ? "ipad" : "iphone" diff --git a/TFM/App/AppModel.swift b/TFM/App/AppModel.swift index 663d394b..a48c3989 100644 --- a/TFM/App/AppModel.swift +++ b/TFM/App/AppModel.swift @@ -144,7 +144,10 @@ final class AppModel { serverMetrics suppliedMetrics: ServerMetricsModel? = nil ) { let dependencies = dependencies ?? .inMemory(hosts: hosts.hosts, groups: hosts.groups) - let connectionCoordinator = dependencies.connectionCoordinator + let externalAuthentication = ExternalAuthCoordinator() + self.externalAuthentication = externalAuthentication + let connectionCoordinator = dependencies.connectionCoordinator.withKeyboardInteractive( + externalAuthentication.keyboardInteractive) self.serverMetrics = suppliedMetrics ?? ServerMetricsModel( @@ -166,7 +169,6 @@ final class AppModel { self.sftpHistory = sftpHistory self.connectionHistory = connectionHistory self.externalToolCalls = externalToolCalls - externalAuthentication = ExternalAuthCoordinator() connections = connectionCoordinator serialPorts = dependencies.serialPortService agentRunRepository = MemoryIndexedAgentRepository( @@ -180,7 +182,9 @@ final class AppModel { vaultSync = dependencies.vaultSync settingsSync = dependencies.settingsSync metricsSync = dependencies.metricsSync - forwarding = PortForwardingController(hosts: hosts, defaults: dependencies.forwardingDefaults) + forwarding = PortForwardingController( + hosts: hosts, defaults: dependencies.forwardingDefaults, + keyboardInteractive: externalAuthentication.keyboardInteractive) sftpTransfers = SFTPTransferCoordinator( store: hosts, clientFactory: { connectionCoordinator.makeSFTPClient() } @@ -249,6 +253,7 @@ final class AppModel { cancelTransfers: { [weak self] deletion in self?.sftpEdits.invalidate(hostID: deletion.hostID) self?.sftpTransfers.cancelAll(for: deletion.hostID) + self?.externalAuthentication.cancel(hostID: deletion.hostID) self?.mcpServer.tools.revokeHost(deletion.hostID) if let self { Task { await self.mcpServer.tools.cancelHostTransfers(deletion.hostID) } } }, @@ -311,6 +316,7 @@ final class AppModel { closeWorkspaces(for: change.id) sftpEdits.invalidate(hostID: change.id) sftpTransfers.cancelAll(for: change.id) + externalAuthentication.cancel(hostID: change.id) mcpServer.tools.revokeHost(change.id) commandHistory.clear(for: change.id) sftpHistory.clear(for: change.id) @@ -621,6 +627,7 @@ final class AppModel { windows.removeAll() for window in liveWindows { window.closeAll() } sftpTransfers.cancelAll() + externalAuthentication.cancelAll() forwarding.suspendAll() let task = Task { [mcpServer, sftpTransfers, agentConnections, agentBackgroundCommands] in async let stopMCP: Void = mcpServer.stop() diff --git a/TFM/App/MCPServerController.swift b/TFM/App/MCPServerController.swift index 54cd996b..79388949 100644 --- a/TFM/App/MCPServerController.swift +++ b/TFM/App/MCPServerController.swift @@ -164,7 +164,7 @@ final class MCPServerController { await tools.stopTransfers() await protocolServer.stop() await http.stop() - externalAuthentication.cancelAll() + externalAuthentication.cancelExternalRequests() connectedClients = 0 status = .stopped } diff --git a/TFM/Features/AI/ConversationTranscriptView.swift b/TFM/Features/AI/ConversationTranscriptView.swift index 116d6468..10da760d 100644 --- a/TFM/Features/AI/ConversationTranscriptView.swift +++ b/TFM/Features/AI/ConversationTranscriptView.swift @@ -650,9 +650,12 @@ private struct AgentSSHAuthenticationCard: View { prompt: request.journeyPrompt, submit: submit, cancel: { - conversation.respondToSSHAuthentication(.cancelled) + conversation.respondToSSHAuthentication(.cancelled, requestID: request.id) }, - usesContainerChrome: false + usesContainerChrome: false, + respondKeyboardInteractive: { + conversation.respondToSSHAuthentication(.keyboardInteractive($0), requestID: request.id) + } ) // A retry or the next route node must start with fresh SecureFields. .id(request.id) @@ -673,7 +676,7 @@ private struct AgentSSHAuthenticationCard: View { keyID: keyID, save: request.requiresPassphrase ? false : save ) - )) + ), requestID: request.id) } } diff --git a/TFM/Features/ExternalRequests/ExternalAuthCoordinator.swift b/TFM/Features/ExternalRequests/ExternalAuthCoordinator.swift index fbec1f61..528d0496 100644 --- a/TFM/Features/ExternalRequests/ExternalAuthCoordinator.swift +++ b/TFM/Features/ExternalRequests/ExternalAuthCoordinator.swift @@ -63,10 +63,30 @@ final class ExternalAuthCoordinator { private var sshWaiters: [UUID: CheckedContinuation] = [:] private var secretWaiters: [UUID: CheckedContinuation] = [:] private var hostKeyWaiters: [UUID: CheckedContinuation] = [:] + private var appAuthenticationRuns: Set = [] private var secretTimeoutTask: Task? var current: Pending? { pending.first } + nonisolated var keyboardInteractive: SSHKeyboardInteractiveHandler { + { [weak self] challenge in + guard let self else { throw CancellationError() } + return try await self.answerAppChallenge(challenge) + } + } + + private func answerAppChallenge(_ challenge: SSHKeyboardInteractiveChallenge) async throws -> [String] { + appAuthenticationRuns.insert(challenge.connectionID) + defer { appAuthenticationRuns.remove(challenge.connectionID) } + return try await handler(clientName: "Termind", runID: challenge.connectionID) + .sshInteraction.respondToChallenge(challenge, invocationID: challenge.id) + } + + func cancelExternalRequests() { + let ids = pending.filter { !appAuthenticationRuns.contains($0.runID) }.map(\.id) + for id in ids { cancel(id) } + } + func handler(clientName: String, runID: UUID) -> Handler { Handler(clientName: clientName, runID: runID, coordinator: self) } @@ -76,8 +96,18 @@ final class ExternalAuthCoordinator { ) async -> AgentSSHAuthenticationResponse { return await withTaskCancellationHandler { await withCheckedContinuation { continuation in + guard !Task.isCancelled else { + continuation.resume(returning: .cancelled) + return + } sshWaiters[request.id] = continuation append(.ssh(request), clientName: clientName, runID: runID) + if request.keyboardInteractive?.prompts.isEmpty == true { + Task { [weak self] in + await Task.yield() + self?.respondSSH(.keyboardInteractive([]), requestID: request.id) + } + } } } onCancel: { Task { @MainActor [weak self] in self?.cancel(request.id) } @@ -113,9 +143,13 @@ final class ExternalAuthCoordinator { } } - func respondSSH(_ response: AgentSSHAuthenticationResponse) { - guard case .ssh? = current?.request, let request = removeCurrent() else { return } - sshWaiters.removeValue(forKey: request.id)?.resume(returning: response) + func respondSSH(_ response: AgentSSHAuthenticationResponse, requestID: UUID? = nil) { + guard let id = requestID ?? current?.id, + let index = pending.firstIndex(where: { $0.id == id }), + case .ssh = pending[index].request + else { return } + if index == 0 { _ = removeCurrent() } else { pending.remove(at: index) } + sshWaiters.removeValue(forKey: id)?.resume(returning: response) } func respondSecret(_ response: AgentUserInputResponse) { @@ -153,6 +187,14 @@ final class ExternalAuthCoordinator { for continuation in hostKeyContinuations { continuation.resume(returning: .deny) } } + func cancel(hostID: UUID) { + let ids = pending.compactMap { item -> UUID? in + if case .ssh(let request) = item.request, request.hostID == hostID { return item.id } + return nil + } + for id in ids { cancel(id) } + } + func cancel(runID: UUID) { let ids = pending.filter { $0.runID == runID }.map(\.id) for id in ids { cancel(id) } diff --git a/TFM/Features/ExternalRequests/ExternalAuthWindow.swift b/TFM/Features/ExternalRequests/ExternalAuthWindow.swift index 0dcec4c6..7dfc1ef0 100644 --- a/TFM/Features/ExternalRequests/ExternalAuthWindow.swift +++ b/TFM/Features/ExternalRequests/ExternalAuthWindow.swift @@ -117,11 +117,14 @@ struct ExternalAuthWindow: View { passphrase: passphrase, keyID: keyID, save: request.requiresPassphrase ? false : save ) - ) + ), requestID: request.id ) }, - cancel: { app.externalAuthentication.respondSSH(.cancelled) }, - usesContainerChrome: false + cancel: { app.externalAuthentication.respondSSH(.cancelled, requestID: request.id) }, + usesContainerChrome: false, + respondKeyboardInteractive: { + app.externalAuthentication.respondSSH(.keyboardInteractive($0), requestID: request.id) + } ) .id(pending.id) case .secret(let request): diff --git a/TFM/Features/SFTP/SFTPBrowserView.swift b/TFM/Features/SFTP/SFTPBrowserView.swift index 4883e5c3..1e459df1 100644 --- a/TFM/Features/SFTP/SFTPBrowserView.swift +++ b/TFM/Features/SFTP/SFTPBrowserView.swift @@ -323,7 +323,8 @@ struct SFTPBrowserView: View { cancel: cancelConnection, reconnectState: sftpSession.reconnectState, retryReconnectNow: sftpSession.retryReconnectNow, - stopReconnecting: sftpSession.stopReconnecting + stopReconnecting: sftpSession.stopReconnecting, + keyboardAuthentication: sftpSession.connection.keyboardAuthentication ) .zIndex(10) } diff --git a/TFM/Features/SFTP/SFTPSessionModel.swift b/TFM/Features/SFTP/SFTPSessionModel.swift index ccd8d7e6..129a1183 100644 --- a/TFM/Features/SFTP/SFTPSessionModel.swift +++ b/TFM/Features/SFTP/SFTPSessionModel.swift @@ -381,6 +381,12 @@ final class SFTPSessionModel: Identifiable { return await self.connection.authorizeHostKey( challenge, using: store ) + }, + keyboardInteractive: { [weak self] challenge in + guard let self, await self.connectionAttemptID == attemptID else { + throw CancellationError() + } + return try await self.connection.answerKeyboardInteractive(challenge) } ), progress: progressHandler(attemptID: attemptID), diff --git a/TFM/Features/Terminal/SSHSessionModel.swift b/TFM/Features/Terminal/SSHSessionModel.swift index 49bc8878..02a80f4d 100644 --- a/TFM/Features/Terminal/SSHSessionModel.swift +++ b/TFM/Features/Terminal/SSHSessionModel.swift @@ -544,7 +544,7 @@ final class SSHSessionModel: Identifiable { credentialFailures = resolution.failures hasConfiguredCredential = resolution.hasConfiguredCredential } - if credentials.isEmpty, suppliedAuthentication == nil { + if credentials.isEmpty, suppliedAuthentication == nil, hasConfiguredCredential { requestAuthentication( for: host, username: username, selectedKeyID: selectedKeyID, @@ -856,7 +856,11 @@ final class SSHSessionModel: Identifiable { try await actor.connect( using: SSHConnectionRequest( configuration: configuration, - authorizeHostKey: authorizeHostKey + authorizeHostKey: authorizeHostKey, + keyboardInteractive: { [weak self] challenge in + guard let self, await self.connectionAttemptID == attemptID else { throw CancellationError() } + return try await self.connection.answerKeyboardInteractive(challenge) + } ), shellIntegrationEnabled: DesktopSettingsKey.isShellIntegrationEnabled, columns: latestViewport.columns, rows: latestViewport.rows, diff --git a/TFM/Features/Terminal/TerminalPlaceholderView.swift b/TFM/Features/Terminal/TerminalPlaceholderView.swift index e18c9c71..3bb985b2 100644 --- a/TFM/Features/Terminal/TerminalPlaceholderView.swift +++ b/TFM/Features/Terminal/TerminalPlaceholderView.swift @@ -150,7 +150,8 @@ struct TerminalPlaceholderView: View { }, reconnectState: sshSession.reconnectState, retryReconnectNow: sshSession.retryReconnectNow, - stopReconnecting: sshSession.stopReconnecting + stopReconnecting: sshSession.stopReconnecting, + keyboardAuthentication: sshSession.connection.keyboardAuthentication ) } } diff --git a/TFM/UI/Components/ConnectionJourneyView.swift b/TFM/UI/Components/ConnectionJourneyView.swift index 642e48e4..3726d1f8 100644 --- a/TFM/UI/Components/ConnectionJourneyView.swift +++ b/TFM/UI/Components/ConnectionJourneyView.swift @@ -157,6 +157,7 @@ struct ConnectionJourneyView: View { var failureCancelLabel: LocalizedStringKey = "Cancel" var failureRetryLabel: LocalizedStringKey = "Retry" var compactFailureActions = false + var keyboardAuthentication: SSHKeyboardInteractiveQueue? = nil @Environment(\.accessibilityReduceMotion) private var reduceMotion @@ -190,6 +191,14 @@ struct ConnectionJourneyView: View { private var content: some View { if journey.showsLogs { logs + } else if let queue = keyboardAuthentication, let challenge = queue.current { + SSHKeyboardInteractiveForm( + challenge: challenge, + submit: { queue.respond(challenge.id, answers: $0) }, + cancel: { queue.cancel(challenge.id) }, keyboard: DesktopConnectionFormKeyboard() + ) + .id(challenge.id) + .connectionFormChrome(isEnabled: true) } else if let prompt = authenticationPrompt { DesktopSSHAuthenticationForm( prompt: prompt, @@ -284,11 +293,13 @@ struct DesktopSSHAuthenticationForm: View { let submit: (Identity.ID?, String, String?, String?, SSHKeyRef.ID?, Bool) -> Void let cancel: () -> Void var usesContainerChrome = true + var respondKeyboardInteractive: (([String]) -> Void)? = nil @Environment(AppModel.self) private var app var body: some View { TermindPresentation.SSHAuthenticationForm( prompt: prompt, submit: submit, cancel: cancel, usesContainerChrome: false, - library: app.hostLibrary, keyboard: DesktopConnectionFormKeyboard() + library: app.hostLibrary, keyboard: DesktopConnectionFormKeyboard(), + respondKeyboardInteractive: respondKeyboardInteractive ) .connectionFormChrome(isEnabled: usesContainerChrome) } diff --git a/TFMTests/AgentHostAuthenticationTests.swift b/TFMTests/AgentHostAuthenticationTests.swift index db74c080..77db53f9 100644 --- a/TFMTests/AgentHostAuthenticationTests.swift +++ b/TFMTests/AgentHostAuthenticationTests.swift @@ -15,6 +15,48 @@ import struct TermindDomain.Host @Suite(.serialized) @MainActor struct AgentHostAuthenticationTests { + @Test func usernameOnlyIdentityDoesNotRequireCredentialEntry() async throws { + let identity = Identity(name: "OTP", username: "deploy") + let host = Host(name: "Server", host: "server.example", identityID: identity.id) + let store = HostLibraryModel(hosts: [host], identities: [identity]) + let resolver = AgentHostResolver(store: store) + let session = AgentSSHConfigurationSession( + resolver: resolver, hostID: host.id, invocationID: UUID(), interaction: .unavailable + ) + let configuration = try await session.configuration() + #expect(configuration.username == "deploy") + #expect(configuration.credentials.isEmpty) + } + + @Test func concurrentConnectionChallengesAreNeverCoalescedWithSavedCredentials() async throws { + let identity = Identity(name: "OTP", username: "deploy") + let host = Host(name: "Server", host: "server.example", identityID: identity.id) + let store = HostLibraryModel(hosts: [host], identities: [identity]) + let interaction = AuthenticationResponder { request in + let challenge = request.keyboardInteractive! + return .keyboardInteractive([challenge.id.uuidString]) + } + let session = AgentSSHConfigurationSession( + resolver: AgentHostResolver(store: store), hostID: host.id, invocationID: UUID(), + interaction: interaction.sshInteraction + ) + let attempt = try await session.configurationAttempt() + let responder = try #require(attempt.request.keyboardInteractive) + let first = SSHKeyboardInteractiveChallenge( + connectionID: UUID(), hostID: host.id, hostname: host.host, port: 22, username: "deploy", + round: 1, name: "PAM", instruction: "", prompts: [.init(text: "OTP", echo: false)]) + var second = first + second.id = UUID() + second.connectionID = UUID() + let secondChallenge = second + async let firstAnswers = responder(first) + async let secondAnswers = responder(secondChallenge) + let answers = try await (firstAnswers, secondAnswers) + #expect(answers.0 == [first.id.uuidString]) + #expect(answers.1 == [second.id.uuidString]) + #expect(store.identities == [identity]) + } + @Test func listingIncludesNestedAndEmptyGroupsAndHostPaths() throws { let root = HostGroup(name: "Production", sortIndex: 0) @@ -135,6 +177,8 @@ struct AgentHostAuthenticationTests { username: "deploy", password: "fallback-password", keyID: key.id, save: false )) + case .keyboardInteractive: + return .cancelled case .passphrase(let keyID): #expect(keyID == key.id) return .submit( diff --git a/TFMTests/ExternalRequestTests.swift b/TFMTests/ExternalRequestTests.swift index 37dc361d..9979b2e3 100644 --- a/TFMTests/ExternalRequestTests.swift +++ b/TFMTests/ExternalRequestTests.swift @@ -17,6 +17,46 @@ import struct TermindDomain.Host @Suite(.serialized) @MainActor struct ExternalAuthCoordinatorTests { + @Test func interactiveResponsesAreScopedAndInformationalRoundsComplete() async throws { + let coordinator = ExternalAuthCoordinator() + let challenge = SSHKeyboardInteractiveChallenge( + connectionID: UUID(), hostID: UUID(), hostname: "server.example", port: 22, + username: "deploy", round: 1, name: "PAM", instruction: "OTP", + prompts: [.init(text: "Code", echo: false)] + ) + let task = Task { try await coordinator.keyboardInteractive(challenge) } + for _ in 0..<1_000 where coordinator.current == nil { await Task.yield() } + #expect(coordinator.current?.id == challenge.id) + coordinator.respondSSH(.keyboardInteractive(["stale"]), requestID: UUID()) + #expect(coordinator.current?.id == challenge.id) + coordinator.respondSSH(.keyboardInteractive(["123456"]), requestID: challenge.id) + #expect(try await task.value == ["123456"]) + var notice = challenge + notice.id = UUID() + notice.prompts = [] + #expect(try await coordinator.keyboardInteractive(notice) == []) + #expect(coordinator.current == nil) + } + + @Test func stoppingMCPPreservesAppAuthentication() async throws { + let coordinator = ExternalAuthCoordinator() + let challenge = SSHKeyboardInteractiveChallenge( + connectionID: UUID(), hostID: UUID(), hostname: "server.example", port: 22, + username: "deploy", round: 1, name: "PAM", instruction: "OTP", + prompts: [.init(text: "Code", echo: false)] + ) + let appTask = Task { try await coordinator.keyboardInteractive(challenge) } + let externalTask = Task { + await coordinator.enqueue(authenticationRequest(name: "MCP"), clientName: "Termind", runID: UUID()) + } + try await waitUntil { coordinator.pending.count == 2 } + coordinator.cancelExternalRequests() + #expect(await externalTask.value == .cancelled) + #expect(coordinator.pending.map(\.id) == [challenge.id]) + coordinator.respondSSH(.keyboardInteractive(["123456"]), requestID: challenge.id) + #expect(try await appTask.value == ["123456"]) + } + @Test func authenticationWindowUsesFocusableNativeChrome() { let window = NSWindow( diff --git a/TFMTests/SFTPResumeTests.swift b/TFMTests/SFTPResumeTests.swift index 8e826173..4936eb49 100644 --- a/TFMTests/SFTPResumeTests.swift +++ b/TFMTests/SFTPResumeTests.swift @@ -2,9 +2,10 @@ import CryptoKit import Foundation import TermindApplication import TermindDomain -import TermindRemoteInfrastructure import Testing +@testable import TermindRemoteInfrastructure + @Suite @MainActor struct SFTPResumeTests { @@ -64,6 +65,33 @@ struct SFTPResumeTests { #expect(coordinator.activeCount == 0) } + @Test func cancellingAPausedUploadDoesNotOpenAnotherAuthenticationPromptForCleanup() async throws { + let queue = SSHKeyboardInteractiveQueue() + let client = ResumeQueueClient(cleanupAuthentication: queue.handler) + let coordinator = SFTPTransferCoordinator(store: HostLibraryModel(), clientFactory: { client }) + defer { queue.cancelAll() } + let root = FileManager.default.temporaryDirectory + coordinator.enqueue( + direction: .upload, batchID: UUID(), localURL: root.appendingPathComponent(UUID().uuidString), + remotePath: "/file", byteCount: 4, hostID: UUID(), + configuration: SSHConnectionConfiguration( + hostID: UUID(), host: "test", port: 22, username: "test", credentials: [], expectedHostKeys: []), + scopedRoot: root, overwrite: true) + let id = try #require(coordinator.sftpTransfers.first?.id) + try await waitUntil { await client.checkpoint?.snapshot.offset == 2 } + coordinator.pause(id) + try await waitUntil { coordinator.sftpTransfers.first?.state == .paused } + coordinator.cancel(id) + try await waitUntil { coordinator.sftpTransfers.first?.state != .paused } + #expect(queue.current == nil) + // A remote temporary file requiring new MFA cannot be silently deleted. + guard case .failed? = coordinator.sftpTransfers.first?.state else { + Issue.record("Expected the existing temporary-file cleanup failure report") + return + } + #expect(await client.connections == 2) + } + @Test func checkpointRejectsChangedSourceAndHashesOnlyAcknowledgedBytes() throws { let state = SFTPResumeState() @@ -95,12 +123,18 @@ private actor ResumeQueueClient: SFTPClientProtocol { var offsets: [Int64] = [] var disconnects = 0 let connectionLoss: Bool - init(connectionLoss: Bool = false) { self.connectionLoss = connectionLoss } + let cleanupAuthentication: SSHKeyboardInteractiveHandler? + var connections = 0 + init(connectionLoss: Bool = false, cleanupAuthentication: SSHKeyboardInteractiveHandler? = nil) { + self.connectionLoss = connectionLoss + self.cleanupAuthentication = cleanupAuthentication + } func run( _ state: SFTPResumeState, token: TransferCancellationToken, progress: @escaping @Sendable (SFTPProgress) -> Void ) async throws { checkpoint = state + if cleanupAuthentication != nil { _ = state.temporaryPath(for: "/file") } let offset = state.snapshot.offset offsets.append(offset) try state.validateSource(SFTPSourceIdentity(byteCount: 4, modifiedAt: nil)) @@ -139,6 +173,20 @@ private actor ResumeQueueClient: SFTPClientProtocol { progress: @escaping @Sendable (SFTPProgress) -> Void ) async throws { throw SFTPResumeFailure.unsupported } func connect(using configuration: SSHConnectionConfiguration, progress: SSHRouteProgressHandler?) async throws {} + func connect( + using request: SSHConnectionRequest, progress: SSHRouteProgressHandler?, + connectionFailure: (@Sendable (String) -> Void)? + ) async throws { + connections += 1 + guard connections > 1, let handler = request.keyboardInteractive ?? cleanupAuthentication else { return } + let configuration = request.configuration + _ = try await handler( + SSHKeyboardInteractiveChallenge( + connectionID: UUID(), hostID: configuration.hostID, hostname: configuration.host, + port: configuration.port, + username: configuration.username, round: 1, name: "OTP", instruction: "", + prompts: [.init(text: "Code", echo: false)])) + } func disconnect() async { disconnects += 1 } func homeDirectory() async throws -> String { "/" } func listDirectory(at path: String) async throws -> [RemoteFile] { [] } diff --git a/TFMTests/SSHKeyboardInteractiveRenderingTests.swift b/TFMTests/SSHKeyboardInteractiveRenderingTests.swift new file mode 100644 index 00000000..8cdfc848 --- /dev/null +++ b/TFMTests/SSHKeyboardInteractiveRenderingTests.swift @@ -0,0 +1,49 @@ +import AppKit +import SwiftUI +import TermindApplication +import TermindPresentation +import Testing + +@Suite(.serialized) @MainActor +struct SSHKeyboardInteractiveRenderingTests { + @Test(arguments: ["en", "zh-Hans", "zh-Hant"]) + func rendersEchoAndSecretPrompts(_ language: String) async throws { + let challenge = SSHKeyboardInteractiveChallenge( + connectionID: UUID(), hostID: UUID(), hostname: "gateway.example.invalid", port: 22, + username: "deploy", round: 1, name: "PAM", instruction: "Enter your account and verification code.", + prompts: [.init(text: "Account", echo: true), .init(text: "Verification code", echo: false)]) + let view = SSHKeyboardInteractiveForm(challenge: challenge, submit: { _ in }, cancel: {}) + .padding(24).frame(width: 520) + .environment(\.locale, Locale(identifier: language)) + let controller = NSHostingController(rootView: view) + let window = NSWindow( + contentRect: NSRect(x: 0, y: 0, width: 520, height: 400), + styleMask: [.titled], backing: .buffered, defer: false) + window.isReleasedWhenClosed = false + window.contentViewController = controller + window.orderFront(nil) + defer { + window.orderOut(nil) + window.contentViewController = nil + window.close() + } + try await Task.sleep(for: .milliseconds(200)) + controller.view.layoutSubtreeIfNeeded() + let fields = editableFields(in: controller.view) + #expect(fields.count == 2) + #expect(fields.filter { $0 is NSSecureTextField }.count == 1) + #expect(fields.allSatisfy { $0.frame.height > 0 && $0.stringValue.isEmpty }) + let bitmap = try #require(controller.view.bitmapImageRepForCachingDisplay(in: controller.view.bounds)) + controller.view.cacheDisplay(in: controller.view.bounds, to: bitmap) + let data = try #require(bitmap.representation(using: .png, properties: [:])) + Attachment.record(data, named: "keyboard-interactive-\(language).png") + let output = FileManager.default.temporaryDirectory.appending(path: "termind-keyboard-\(language).png") + try data.write(to: output) + print("Keyboard authentication snapshot: \(output.path)") + } + + private func editableFields(in view: NSView) -> [NSTextField] { + if let field = view as? NSTextField, field.isEditable { return [field] } + return view.subviews.flatMap { editableFields(in: $0) } + } +} diff --git a/TFMTests/SSHKeyboardInteractiveTests.swift b/TFMTests/SSHKeyboardInteractiveTests.swift new file mode 100644 index 00000000..908240c1 --- /dev/null +++ b/TFMTests/SSHKeyboardInteractiveTests.swift @@ -0,0 +1,189 @@ +import Foundation +import TermindApplication +import TermindDomain +import TermindRemoteInfrastructure +import Testing + +@testable import Termind + +/// Opt-in wire tests: see Scripts/ssh-authentication-fixture.py. All ordinary +/// queue, preparation and cancellation tests run without this external fixture. +@Suite(.enabled(if: ProcessInfo.processInfo.environment["TERMIND_SSH_FIXTURE"] != nil), .serialized) +struct SSHKeyboardInteractiveWireTests { + @Test(arguments: ["none", "password", "kbd", "multi", "push", "password-mfa", "publickey-mfa", "kbd-password"]) + func execAuthenticatesOnTheSameTransport(_ mode: String) async throws { + let fixture = try fixture() + let recorder = WireChallengeRecorder() + let request = request(mode: mode, fixture: fixture, handler: recorder.handler) + let session = SSHExecSession() + do { + try await session.connect(using: request) + let result = try await session.execute( + "fixture", workingDirectory: nil, stdin: nil, timeout: 5, outputLimit: 1024, onOutput: nil + ) + #expect(result.stdout == "fixture\n") + #expect(result.exitCode == 0) + let rounds = await recorder.challenges + #expect(rounds.count == (mode == "multi" ? 2 : ["none", "password"].contains(mode) ? 0 : 1)) + if mode == "multi" { + #expect(rounds[0].prompts.map(\.echo) == [true, false]) + #expect(rounds[0].connectionID == rounds[1].connectionID) + #expect(rounds[0].id != rounds[1].id) + #expect(rounds.map(\.round) == [1, 2]) + } + } catch { + await session.disconnect() + throw error + } + await session.disconnect() + } + + @Test func sftpUsesTheCoordinatorResponderWithoutAPassword() async throws { + let fixture = try fixture() + let recorder = WireChallengeRecorder() + let coordinator = RemoteConnectionCoordinator(keyboardInteractive: recorder.handler) + let client = try await coordinator.connectSFTP(using: request(mode: "kbd", fixture: fixture, handler: nil)) + do { + let files = try await client.listDirectory(at: "/") + #expect(files.map(\.name) == ["fixture.txt"]) + #expect(await recorder.challenges.count == 1) + } catch { + await client.disconnect() + throw error + } + await client.disconnect() + } + + @Test func terminalUsesInteractiveAuthentication() async throws { + let fixture = try fixture() + let recorder = WireChallengeRecorder() + let session = SSHSessionActor(output: { _ in }, exit: { _ in }) + try await session.connect( + using: request(mode: "kbd", fixture: fixture, handler: recorder.handler), + shellIntegrationEnabled: false, columns: 80, rows: 24 + ) + #expect(await recorder.challenges.count == 1) + await session.disconnect() + } + + @Test func forwardingAuthenticatesAndReconnectionRequestsFreshAnswers() async throws { + let fixture = try fixture() + let recorder = WireChallengeRecorder() + let session = SSHForwardingSession(connectionHandler: { _, _ in }, failureHandler: { _ in }) + let request = request(mode: "kbd", fixture: fixture, handler: recorder.handler) + try await session.connect(request) + await session.disconnect() + try await session.connect(request) + await session.disconnect() + let challenges = await recorder.challenges + #expect(challenges.count == 2) + #expect(challenges[0].connectionID != challenges[1].connectionID) + } + + @Test func jumpAndDestinationNeverShareAnswers() async throws { + let fixture = try fixture() + let recorder = WireChallengeRecorder() + var request = request(mode: "multi", fixture: fixture, handler: recorder.handler) + let jumpID = UUID() + request.configuration.jumps = [ + SSHRouteHopConfiguration( + id: jumpID, name: "Jump", host: "127.0.0.1", port: fixture.port, + username: "kbd", credentials: [], expectedHostKeys: [] + ) + ] + let session = SSHExecSession() + try await session.connect(using: request) + await session.disconnect() + let challenges = await recorder.challenges + #expect(challenges.map(\.hostID) == [jumpID, request.configuration.hostID, request.configuration.hostID]) + #expect(challenges[0].connectionID != challenges[1].connectionID) + } + + @Test @MainActor func cancellingWhileWaitingClosesTheConnectionAndPrompt() async throws { + let fixture = try fixture() + let queue = SSHKeyboardInteractiveQueue() + let request = request(mode: "kbd", fixture: fixture, handler: queue.handler) + let session = SSHExecSession() + let opening = Task { try await session.connect(using: request) } + for _ in 0..<500 where queue.current == nil { try await Task.sleep(for: .milliseconds(10)) } + #expect(queue.current != nil) + opening.cancel() + await #expect(throws: CancellationError.self) { try await opening.value } + for _ in 0..<100 where queue.current != nil { await Task.yield() } + #expect(queue.current == nil) + await session.disconnect() + } + + @Test @MainActor func transferCancellationDismissesPendingAuthentication() async throws { + let fixture = try fixture() + let queue = SSHKeyboardInteractiveQueue() + let coordinator = SFTPTransferCoordinator( + store: HostLibraryModel(), clientFactory: { SFTPClient(keyboardInteractive: queue.handler) }) + var configuration = request(mode: "kbd", fixture: fixture, handler: nil).configuration + configuration.expectedHostKeys = [.init(algorithm: .rsa, fingerprint: fixture.hostFingerprint)] + let root = FileManager.default.temporaryDirectory + coordinator.enqueue( + direction: .download, batchID: UUID(), localURL: root.appending(path: UUID().uuidString), + remotePath: "/fixture.txt", byteCount: 8, hostID: configuration.hostID, + configuration: configuration, scopedRoot: root, overwrite: true) + for _ in 0..<500 where queue.current == nil { try await Task.sleep(for: .milliseconds(10)) } + #expect(queue.current != nil) + coordinator.cancelAll() + await coordinator.waitForCancellation() + #expect(queue.current == nil) + #expect(coordinator.sftpTransfers.first?.state == .cancelled) + } + + @Test func rejectedChallengeRetainsAuthenticationFailureClassification() async throws { + let fixture = try fixture() + let session = SSHExecSession() + do { + try await session.connect(using: request(mode: "kbd", fixture: fixture, handler: { _ in ["wrong"] })) + Issue.record("Expected an authentication failure") + } catch let error as TermindSSHError { + #expect(error.shouldRequestAuthentication) + } + await session.disconnect() + } + + private struct Fixture: Decodable { + var port: Int + var privateKey: String + var hostFingerprint: String + } + + private func fixture() throws -> Fixture { + let path = try #require(ProcessInfo.processInfo.environment["TERMIND_SSH_FIXTURE"]) + return try JSONDecoder().decode(Fixture.self, from: Data(contentsOf: URL(fileURLWithPath: path))) + } + + private func request( + mode: String, fixture: Fixture, handler: SSHKeyboardInteractiveHandler? + ) -> SSHConnectionRequest { + var credentials: [SSHCredentials] = [] + if ["password", "password-mfa", "kbd-password"].contains(mode) { + credentials = [.password("fixture-password")] + } + if mode == "publickey-mfa" { + credentials = [.privateKey(privateKey: fixture.privateKey, publicKey: nil, passphrase: nil)] + } + return SSHConnectionRequest( + configuration: SSHConnectionConfiguration( + hostID: UUID(), host: "127.0.0.1", port: fixture.port, username: mode, + credentials: credentials, expectedHostKeys: [] + ), authorizeHostKey: { _ in .accept }, keyboardInteractive: handler + ) + } +} + +private actor WireChallengeRecorder { + var challenges: [SSHKeyboardInteractiveChallenge] = [] + nonisolated var handler: SSHKeyboardInteractiveHandler { { try await self.answer($0) } } + private func answer(_ challenge: SSHKeyboardInteractiveChallenge) throws -> [String] { + challenges.append(challenge) + return challenge.prompts.map { prompt in + if prompt.echo { return "部署 user" } + return prompt.text == "Optional" ? "" : "123456" + } + } +}