Files
Termind/Frameworks/TermindApplication/Connections/RemoteSessionConnectionController.swift
T
Kaede Akino 816a6ff121 feat(ssh): support hardware security key authentication
Add native security key registration, reference import, and signing on macOS and iOS. Store references in Vault metadata and route authentication through connection, Assistant, MCP, and agent forwarding flows.

Preserve signing cancellation and actionable authentication errors for security keys and Secure Enclave. Pin the released libssh2 forks as nested submodules and refresh package resolution.
2026-09-28 23:37:49 +08:00

269 lines
9.2 KiB
Swift

import Foundation
import TermindDomain
import struct TermindDomain.Host
public nonisolated struct SSHHostKeyAuthorizationRequest: Hashable, Sendable {
public var hostID: Host.ID
public var prompt: SSHHostKeyPrompt
public init(hostID: Host.ID, prompt: SSHHostKeyPrompt) {
self.hostID = hostID
self.prompt = prompt
}
}
/// One MainActor owner for a single interactive host-key decision. Feature
/// models keep their own lifecycle and presentation state while sharing the
/// continuation, cancellation, and temporary/persistent trust rules.
@MainActor
@Observable
public final class SSHHostKeyAuthorizationCoordinator {
public private(set) var pending: SSHHostKeyAuthorizationRequest?
public var temporaryHostKeys: [Host.ID: SSHHostKeyIdentity] = [:]
private var authorizationID: UUID?
private var continuation: CheckedContinuation<SSHHostKeyDecision, Never>?
public init() {}
public func authorize(
_ challenge: SSHHostKeyChallenge,
onPrompt: (SSHHostKeyAuthorizationRequest) -> Void = { _ in }
) async -> SSHHostKeyDecision {
if challenge.expectedHostKeys.contains(challenge.observation.identity) { return .accept }
finish(.reject)
let id = UUID()
authorizationID = id
let request = SSHHostKeyAuthorizationRequest(
hostID: challenge.hostID,
prompt: SSHHostKeyPrompt(challenge: challenge))
return await withTaskCancellationHandler {
await withCheckedContinuation { continuation in
guard authorizationID == id else {
continuation.resume(returning: .reject)
return
}
self.continuation = continuation
pending = request
onPrompt(request)
}
} onCancel: {
Task { @MainActor [weak self] in self?.cancel(id: id) }
}
}
@discardableResult
public func continueOnce(_ observation: SSHHostKeyObservation) -> Bool {
guard pending?.prompt.observation == observation, let hostID = pending?.hostID else { return false }
temporaryHostKeys[hostID] = observation.identity
finish(.accept)
return true
}
@discardableResult
public func trust(
_ observation: SSHHostKeyObservation, using store: any RemoteSessionHostStore,
addedAt: Date = .now
) -> Bool {
guard pending?.prompt.observation == observation, let hostID = pending?.hostID else { return false }
store.trust(
KnownHostEntry(
hostname: observation.hostname, port: observation.port,
algorithm: observation.algorithm, fingerprint: observation.fingerprint,
addedAt: addedAt))
temporaryHostKeys[hostID] = nil
finish(.accept)
return true
}
public func reject() { finish(.reject) }
public func cancelPending() { finish(.reject) }
public func reset() {
finish(.reject)
temporaryHostKeys.removeAll()
}
private func cancel(id: UUID) {
guard authorizationID == id else { return }
finish(.reject)
}
private func finish(_ decision: SSHHostKeyDecision) {
let continuation = continuation
self.continuation = nil
authorizationID = nil
pending = nil
continuation?.resume(returning: decision)
}
}
/// Shared connection-state composition for interactive sessions.
/// Transport-specific models own their I/O, while this object owns the common
/// connection journey and retry lifecycle shown by both Terminal and SFTP.
@Observable
public final class RemoteSessionConnectionController {
public let journey = ConnectionJourney()
public let securityKeySigning = SecurityKeySigningCoordinator()
public let keyboardAuthentication = SSHKeyboardInteractiveQueue()
public let hostKeyAuthorization = SSHHostKeyAuthorizationCoordinator()
private var keyboardAuthenticationID: UUID?
public let reconnect: SessionReconnectController
public var pendingHostKey: SSHHostKeyPrompt? { hostKeyAuthorization.pending?.prompt }
public var temporaryHostKeys: [Host.ID: SSHHostKeyIdentity] {
get { hostKeyAuthorization.temporaryHostKeys }
set { hostKeyAuthorization.temporaryHostKeys = newValue }
}
public private(set) var hasConnectedOnce = false
public private(set) var isRecovering = false
public var reconnectState: ReconnectState? { reconnect.state }
public init(reconnect: SessionReconnectController = SessionReconnectController()) {
self.reconnect = reconnect
}
public func disconnect() {
keyboardAuthenticationID = nil
keyboardAuthentication.cancelAll()
securityKeySigning.cancel()
hostKeyAuthorization.reset()
reconnect.stop()
hasConnectedOnce = false
isRecovering = false
journey.hide()
}
@discardableResult
public func markConnected() -> Bool {
let wasRecovering = isRecovering
let initial = !hasConnectedOnce
hasConnectedOnce = true
isRecovering = false
reconnect.markConnected(initial: initial)
return wasRecovering
}
@discardableResult
public func scheduleRecovery(
after error: String, retry: @escaping SessionReconnectController.RetryAction
) -> Bool {
guard hasConnectedOnce else { return false }
let announcesLoss = !isRecovering
isRecovering = true
reconnect.schedule(after: error, retry: retry)
return announcesLoss
}
public func pauseForUserInteraction() {
reconnect.pauseForUserInteraction()
}
@discardableResult
public func stopRecovery() -> ReconnectState? {
let state = reconnect.state
reconnect.stop()
isRecovering = false
return state
}
public func suspendForForegroundResume(reconnecting: Bool) {
reconnect.stop()
isRecovering = reconnecting && hasConnectedOnce
}
public func resetRecovery() {
reconnect.stop()
hasConnectedOnce = false
isRecovering = false
}
public func trustHostKey(
_ observation: SSHHostKeyObservation, using store: any RemoteSessionHostStore
) {
guard hostKeyAuthorization.trust(observation, using: store) else { return }
journey.resume()
}
public func useTemporaryHostKey(_ observation: SSHHostKeyObservation) -> Bool {
guard hostKeyAuthorization.continueOnce(observation) else { return false }
journey.resume()
return true
}
public func rejectHostKey() { hostKeyAuthorization.reject() }
public func signWithSecurityKey(_ request: SecurityKeySigningRequest) async throws -> SecurityKeyAssertion {
reconnect.pauseForUserInteraction()
journey.waitForAuthentication(at: request.hostID)
do {
let assertion = try await securityKeySigning.sign(request)
try Task.checkCancellation()
journey.resume()
return assertion
} catch {
// Cancellation is not an accepted authentication checkpoint.
if journey.phase == .waiting {
journey.fail(
error is CancellationError
? String(localized: "Authentication was cancelled. Retry to continue connecting.")
: error.localizedDescription,
at: request.hostID)
}
throw error
}
}
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) async -> SSHHostKeyDecision {
return await hostKeyAuthorization.authorize(challenge) { [weak self] request in
self?.waitForHostKey(request.prompt, hostID: request.hostID)
}
}
public func waitForHostKey(_ prompt: SSHHostKeyPrompt, hostID: Host.ID) {
reconnect.pauseForUserInteraction()
journey.waitForHostKey(at: hostID, fingerprint: prompt.observation.fingerprint)
}
public func progressHandler(
attemptID: UUID, isCurrent: @escaping @MainActor @Sendable (UUID) -> Bool
) -> SSHRouteProgressHandler {
{ [weak self] event in
Task { @MainActor in
guard isCurrent(attemptID) else { return }
self?.journey.receive(event)
}
}
}
public func presentReconnectJourney(
host: Host?, using store: any RemoteSessionHostStore,
purpose: ConnectionPurpose, username: String?
) {
guard let host else { return }
journey.begin(
host: host, route: store.route(for: host), purpose: purpose,
username: username, mode: .reconnection
)
}
public func clearHostKeyPrompt() {
hostKeyAuthorization.cancelPending()
}
}