Files
Termind/Frameworks/TermindRemoteInfrastructure/SSH/SSHKeyboardInteractiveExchange.swift
T
Kaede Akino ea099fc5a6 fix(ssh): support Tailscale approval on ordinary connections
Detect Tailscale SSH by server identification and authenticate with the configured username without requiring credentials or Pro. Keep browser approval concurrent with transport polling and dismiss prompts when authentication finishes. Preserve managed Tailnet access limits and add loopback regression coverage.
2026-10-17 06:31:55 +08:00

305 lines
12 KiB
Swift

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) }
}
}
}