Add SSH keyboard-interactive authentication across connection flows
This commit is contained in:
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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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()
|
||||
@@ -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 {
|
||||
|
||||
@@ -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() }
|
||||
|
||||
@@ -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
@@ -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()
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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) }
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user