308 lines
12 KiB
Swift
308 lines
12 KiB
Swift
// SPDX-FileCopyrightText: 2026 Kaede Akino
|
|
// SPDX-License-Identifier: GPL-3.0-only
|
|
|
|
import CSSH2
|
|
import Darwin
|
|
import Foundation
|
|
import TermindApplication
|
|
import TermindDomain
|
|
|
|
/// 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 let allowsUserInteraction: Bool
|
|
private let securityKeySigning: SecurityKeySigningHandler?
|
|
private var assertionResult: Result<SecurityKeyAssertion, any Error>?
|
|
private var cancelled = false
|
|
private var task: Task<Void, Never>?
|
|
private var result: Result<[String], any Error>?
|
|
private var requestID: UUID?
|
|
private var approvalID: UUID?
|
|
private var approvalTask: Task<Void, Never>?
|
|
private var approvalResult: Result<[String], any Error>?
|
|
private var retiredApprovals: [Task<Void, Never>] = []
|
|
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?,
|
|
securityKeySigning: SecurityKeySigningHandler? = nil, allowsUserInteraction: Bool = true
|
|
) {
|
|
self.securityKeySigning = securityKeySigning
|
|
self.allowsUserInteraction = allowsUserInteraction
|
|
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
|
|
let approval = approvalTask
|
|
result = .failure(CancellationError())
|
|
assertionResult = .failure(CancellationError())
|
|
condition.broadcast()
|
|
condition.unlock()
|
|
pending?.cancel()
|
|
approval?.cancel()
|
|
}
|
|
|
|
func checkCancellation() throws {
|
|
condition.lock()
|
|
let stopped = cancelled
|
|
condition.unlock()
|
|
if stopped { throw CancellationError() }
|
|
}
|
|
|
|
func presentTailscaleApproval(_ banner: String) throws {
|
|
try checkTailscaleApproval()
|
|
guard allowsUserInteraction, let handler else { throw SSHInteractionRequired() }
|
|
endTailscaleApproval()
|
|
round += 1
|
|
let challenge = SSHKeyboardInteractiveChallenge(
|
|
connectionID: connectionID, hostID: hostID, hostname: hostname, port: port,
|
|
username: username, round: round, name: "", instruction: banner, prompts: [],
|
|
isTailscaleApproval: true)
|
|
condition.lock()
|
|
defer { condition.unlock() }
|
|
guard !cancelled else { throw CancellationError() }
|
|
approvalID = challenge.id
|
|
approvalTask = Task.detached(priority: .userInitiated) { [weak self] in
|
|
let response: Result<[String], any Error>
|
|
do {
|
|
try Task.checkCancellation()
|
|
response = .success(try await handler(challenge))
|
|
} catch { response = .failure(error) }
|
|
self?.finishApproval(challenge.id, result: response)
|
|
}
|
|
}
|
|
|
|
func checkTailscaleApproval() throws {
|
|
try checkCancellation()
|
|
let response = condition.withLock { approvalResult }
|
|
// A responder's acknowledgement is never proof that the server accepted authentication.
|
|
if case .failure(let error) = response { throw error }
|
|
}
|
|
|
|
func endTailscaleApproval() {
|
|
condition.lock()
|
|
let pending = approvalTask
|
|
approvalID = nil
|
|
approvalResult = nil
|
|
approvalTask = nil
|
|
if let pending { retiredApprovals.append(pending) }
|
|
condition.unlock()
|
|
// Retire the ID first so dismissing our own prompt cannot cancel a successful connection.
|
|
pending?.cancel()
|
|
}
|
|
|
|
func waitForTailscaleApprovalDismissal() async {
|
|
let pending = condition.withLock {
|
|
let pending = retiredApprovals
|
|
retiredApprovals.removeAll()
|
|
return pending
|
|
}
|
|
for task in pending { await task.value }
|
|
}
|
|
|
|
private func finishApproval(_ id: UUID, result: Result<[String], any Error>) {
|
|
condition.withLock {
|
|
if approvalID == id, !cancelled { approvalResult = result }
|
|
}
|
|
}
|
|
|
|
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 sign(credential: SecurityKeyCredential, keyName: String, data: Data) throws -> Data {
|
|
try checkCancellation()
|
|
guard let securityKeySigning else { throw SSHInteractionRequired() }
|
|
let request = SecurityKeySigningRequest(
|
|
hostID: hostID, hostname: hostname, credential: credential, challenge: data,
|
|
allowsUserInteraction: allowsUserInteraction, keyName: keyName)
|
|
condition.lock()
|
|
guard !cancelled else {
|
|
condition.unlock()
|
|
throw CancellationError()
|
|
}
|
|
assertionResult = nil
|
|
requestID = request.id
|
|
task = Task.detached(priority: .userInitiated) { [weak self] in
|
|
let response: Result<SecurityKeyAssertion, any Error>
|
|
do { response = .success(try await securityKeySigning(request)) } catch { response = .failure(error) }
|
|
self?.finishSignature(request.id, result: response)
|
|
}
|
|
while assertionResult == nil { condition.wait() }
|
|
let answer = assertionResult!
|
|
assertionResult = nil
|
|
task = nil
|
|
requestID = nil
|
|
condition.unlock()
|
|
try checkCancellation()
|
|
return try WebAuthnSSH.signature(answer.get(), for: request)
|
|
}
|
|
|
|
private func finishSignature(_ id: UUID, result: Result<SecurityKeyAssertion, any Error>) {
|
|
condition.lock()
|
|
if requestID == id, !cancelled {
|
|
assertionResult = 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) }
|
|
}
|
|
}
|
|
}
|