Add SSH keyboard-interactive authentication across connection flows

This commit is contained in:
Kaede Akino committed 2026-09-18 14:22:11 +08:00
1 parent 9149a9d0c9
commit ec0f685cfe
51 files changed
+1657 -186

No files matched your search

@@ -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(
@@ -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)
}
}
@@ -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" : {
@@ -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
@@ -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)
}
@@ -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
@@ -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
}
}
@@ -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)
}
@@ -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)
}
}
}
@@ -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,
@@ -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 {
@@ -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
}
}
@@ -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 {
@@ -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)
}
}
@@ -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
@@ -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 {
@@ -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)
)
}
}
@@ -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
)
}
@@ -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
@@ -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
}
}
@@ -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<Void, Never>?
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<CChar>?, _ nameLength: Int32,
_ instruction: UnsafePointer<CChar>?, _ instructionLength: Int32,
_ promptCount: Int32,
_ prompts: UnsafePointer<LIBSSH2_USERAUTH_KBDINT_PROMPT>?,
_ responses: UnsafeMutablePointer<LIBSSH2_USERAUTH_KBDINT_RESPONSE>?,
_ abstract: UnsafeMutablePointer<UnsafeMutableRawPointer?>?
) {
guard let opaque = abstract?.pointee else { return }
let callback = Unmanaged<SSHKeyboardInteractiveCallback>.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<CChar>?, 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<CChar>?, nameLength: Int32,
instruction: UnsafePointer<CChar>?, instructionLength: Int32,
count: Int32, prompts: UnsafePointer<LIBSSH2_USERAUTH_KBDINT_PROMPT>?,
responses: UnsafeMutablePointer<LIBSSH2_USERAUTH_KBDINT_RESPONSE>?
) {
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..<Int(count)).map { index in
let prompt = prompts![index]
return SSHKeyboardInteractiveChallenge.Prompt(
text: string(
prompt.text.map { UnsafeRawPointer($0).assumingMemoryBound(to: CChar.self) },
length: Int(prompt.length)), echo: prompt.echo != 0
)
}
let answers = try exchange.answer(
name: string(name, length: Int(nameLength)),
instruction: string(instruction, length: Int(instructionLength)), prompts: questions
)
for (index, answer) in answers.enumerated() {
let bytes = Array(answer.utf8)
guard bytes.count <= Int(UInt32.max), let buffer = malloc(bytes.count + 1) else {
throw TermindSSHError.library(String(localized: "Invalid authentication response."))
}
bytes.withUnsafeBytes { source in
if let base = source.baseAddress { buffer.copyMemory(from: base, byteCount: bytes.count) }
}
buffer.storeBytes(of: UInt8(0), toByteOffset: bytes.count, as: UInt8.self)
responses![index].text = buffer.assumingMemoryBound(to: CChar.self)
responses![index].length = UInt32(bytes.count)
}
} catch {
exchange.recordFailure(error)
// The callback has no error return. Stop the transport before
// libssh2 can submit empty responses or await another server packet.
if socket >= 0 { _ = Darwin.shutdown(socket, SHUT_RDWR) }
}
}
}
@@ -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))
@@ -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<CChar>?, _ nameLength: Int32,
_ instruction: UnsafePointer<CChar>?, _ instructionLength: Int32,
_ promptCount: Int32,
_ prompts: UnsafePointer<LIBSSH2_USERAUTH_KBDINT_PROMPT>?,
_ responses: UnsafeMutablePointer<LIBSSH2_USERAUTH_KBDINT_RESPONSE>?,
_ abstract: UnsafeMutablePointer<UnsafeMutableRawPointer?>?
) {
guard promptCount > 0, let responses, let opaque = abstract?.pointee else { return }
let password = Unmanaged<SSHKeyboardPassword>.fromOpaque(opaque)
.takeUnretainedValue().value
for index in 0..<Int(promptCount) {
responses[index].text = password.withCString(Darwin.strdup)
responses[index].length = UInt32(password.utf8.count)
func cancel() {
lock.withLock {
if socket >= 0 { _ = Darwin.shutdown(socket, SHUT_RDWR) }
}
}
func finish() { lock.withLock { socket = -1 } }
}
@@ -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
+21
View File
@@ -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,
+193
View File
@@ -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()
+21 -4
View File
@@ -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 {
+1
View File
@@ -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() }
+17
View File
@@ -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)
@@ -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
}
}
@@ -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 {
@@ -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)
}
@@ -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)
}
)
@@ -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)
}
}
@@ -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
@@ -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"
+10 -3
View File
@@ -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()
+1 -1
View File
@@ -164,7 +164,7 @@ final class MCPServerController {
await tools.stopTransfers()
await protocolServer.stop()
await http.stop()
externalAuthentication.cancelAll()
externalAuthentication.cancelExternalRequests()
connectedClients = 0
status = .stopped
}
@@ -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)
}
}
@@ -63,10 +63,30 @@ final class ExternalAuthCoordinator {
private var sshWaiters: [UUID: CheckedContinuation<AgentSSHAuthenticationResponse, Never>] = [:]
private var secretWaiters: [UUID: CheckedContinuation<AgentUserInputResponse, Never>] = [:]
private var hostKeyWaiters: [UUID: CheckedContinuation<AgentSSHHostKeyResponse, Never>] = [:]
private var appAuthenticationRuns: Set<UUID> = []
private var secretTimeoutTask: Task<Void, Never>?
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) }
@@ -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):
+2 -1
View File
@@ -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)
}
+6
View File
@@ -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),
+6 -2
View File
@@ -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,
@@ -150,7 +150,8 @@ struct TerminalPlaceholderView: View {
},
reconnectState: sshSession.reconnectState,
retryReconnectNow: sshSession.retryReconnectNow,
stopReconnecting: sshSession.stopReconnecting
stopReconnecting: sshSession.stopReconnecting,
keyboardAuthentication: sshSession.connection.keyboardAuthentication
)
}
}
+12 -1
View File
@@ -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)
}
@@ -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(
+40
View File
@@ -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(
+50 -2
View File
@@ -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] { [] }
@@ -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) }
}
}
+189
View File
@@ -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"
}
}
}