112 lines
3.8 KiB
Swift
112 lines
3.8 KiB
Swift
import Foundation
|
|
|
|
/// 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)
|
|
}
|
|
}
|
|
}
|