diff --git a/FrameworkTests/RemoteConnectionURLTests.swift b/FrameworkTests/RemoteConnectionURLTests.swift index a1952be6..fbe443b7 100644 --- a/FrameworkTests/RemoteConnectionURLTests.swift +++ b/FrameworkTests/RemoteConnectionURLTests.swift @@ -8,7 +8,11 @@ struct RemoteConnectionURLTests { @Test func parsesSSHAndLegacyFingerprint() throws { let value = try RemoteConnectionURLParser.parse( - #require(URL(string: "ssh://deploy;fingerprint=ssh-rsa-00-11-22-33-44-55-66-77-88-99-aa-bb-cc-dd-ee-ff@example.com:2222/ignored")) + #require( + URL( + string: + "ssh://deploy;fingerprint=ssh-rsa-00-11-22-33-44-55-66-77-88-99-aa-bb-cc-dd-ee-ff@example.com:2222/ignored" + )) ) #expect(value.kind == .ssh) #expect(value.username == "deploy") @@ -16,8 +20,12 @@ struct RemoteConnectionURLTests { #expect(value.port == 2222) #expect(value.path == nil) #expect(value.hostKeyFingerprint?.algorithm == .rsa) - #expect(value.hostKeyFingerprint?.md5 == Data([0x00, 0x11, 0x22, 0x33, 0x44, 0x55, 0x66, 0x77, - 0x88, 0x99, 0xaa, 0xbb, 0xcc, 0xdd, 0xee, 0xff])) + #expect( + value.hostKeyFingerprint?.legacyMD5 + == Data([ + 0x00, 0x11, 0x22, 0x33, 0x44, 0x55, 0x66, 0x77, + 0x88, 0x99, 0xaa, 0xbb, 0xcc, 0xdd, 0xee, 0xff, + ])) } @Test @@ -66,7 +74,7 @@ struct RemoteConnectionURLTests { let fingerprint = try #require(parsed.hostKeyFingerprint) let matching = SSHHostKeyObservation( hostname: "host", port: 22, algorithm: .rsa, fingerprint: "SHA256:test", - legacyMD5Fingerprint: fingerprint.md5) + legacyMD5Fingerprint: fingerprint.legacyMD5) let different = SSHHostKeyObservation( hostname: "host", port: 22, algorithm: .rsa, fingerprint: "SHA256:test", legacyMD5Fingerprint: Data(repeating: 0xff, count: 16)) diff --git a/FrameworkTests/SSHHostKeyAuthorizationTests.swift b/FrameworkTests/SSHHostKeyAuthorizationTests.swift index 750ebd2a..41c01fe0 100644 --- a/FrameworkTests/SSHHostKeyAuthorizationTests.swift +++ b/FrameworkTests/SSHHostKeyAuthorizationTests.swift @@ -1,10 +1,108 @@ import Foundation import TermindApplication import TermindDomain +import TermindRemoteInfrastructure import Testing @Suite @MainActor struct SSHHostKeyAuthorizationTests { + @Test func synchronousPromptResolutionCompletesExactlyOnce() async { + let coordinator = SSHHostKeyAuthorizationCoordinator() + let observation = SSHHostKeyObservation( + hostname: "server.example", port: 22, + algorithm: .ed25519, fingerprint: "SHA256:new") + let decision = await coordinator.authorize( + SSHHostKeyChallenge(hostID: UUID(), observation: observation, expectedHostKeys: []) + ) { _ in + #expect(coordinator.continueOnce(observation)) + } + + #expect(decision == .accept) + #expect(coordinator.pending == nil) + coordinator.reject() + #expect(coordinator.pending == nil) + } + + @Test func authenticationPromptResolverSharesHostAndProxyProjection() throws { + let identity = Identity(name: "Deploy", username: "deploy") + var host = Host(name: "Server", host: "server.example", identityID: identity.id) + host.proxy = HostProxy( + hostname: "proxy.example", port: 8080, + username: "proxy-user", identityID: identity.id) + let store = HostLibraryModel(hosts: [host], identities: [identity]) + + let direct = SSHAuthenticationPromptResolver.host( + host, using: store, failureMessage: "failed", + mode: .serverChallenge, allowsSaving: false) + #expect(direct.selectedIdentityID == identity.id) + #expect(direct.username == identity.username) + #expect(direct.mode == .serverChallenge) + #expect(!direct.allowsSaving) + + let proxy = try #require( + SSHAuthenticationPromptResolver.proxy( + for: host, using: store, + submission: SSHProxyAuthenticationSubmission(username: "retry", identityID: identity.id), + allowsSaving: false)) + #expect(proxy.isProxy) + #expect(proxy.username == "retry") + #expect(proxy.selectedIdentityID == identity.id) + #expect(!proxy.allowsSaving) + } + + @Test func knownKeyWithMatchingConstraintBypassesInteraction() async throws { + let hostID = UUID() + let observation = constrainedObservation() + let recorder = HostKeyAuthorizerRecorder() + + try await SSHConnectionOpener.authorizeHostKey( + hostID: hostID, observed: observation, + expected: [observation.identity], constraint: matchingConstraint(), + authorizer: { challenge in + await recorder.record(challenge) + return .accept + }) + + #expect(await recorder.challenges.isEmpty) + } + + @Test func mismatchedConstraintRejectsWithoutInteraction() async { + let observation = constrainedObservation() + let recorder = HostKeyAuthorizerRecorder() + let mismatch = SSHHostKeyConstraint(algorithm: .rsa, legacyMD5: Data(repeating: 0xff, count: 16)) + + await #expect(throws: TermindSSHError.self) { + try await SSHConnectionOpener.authorizeHostKey( + hostID: UUID(), observed: observation, expected: [], constraint: mismatch, + authorizer: { challenge in + await recorder.record(challenge) + return .accept + }) + } + #expect(await recorder.challenges.isEmpty) + } + + @Test func matchingConstraintMarksFirstUsePromptButCannotOverrideKnownHosts() async throws { + let observation = constrainedObservation() + let recorder = HostKeyAuthorizerRecorder() + try await SSHConnectionOpener.authorizeHostKey( + hostID: UUID(), observed: observation, expected: [], constraint: matchingConstraint(), + authorizer: { challenge in + await recorder.record(challenge) + return .accept + }) + let challenge = try #require(await recorder.challenges.first) + #expect(challenge.provisionalConstraintMatched) + #expect(SSHHostKeyPrompt(challenge: challenge).uriFingerprintMatched) + + await #expect(throws: TermindSSHError.self) { + try await SSHConnectionOpener.authorizeHostKey( + hostID: UUID(), observed: observation, + expected: [SSHHostKeyIdentity(algorithm: .ed25519, fingerprint: "SHA256:old")], + constraint: matchingConstraint(), authorizer: { _ in .accept }) + } + } + @Test func temporaryTrustResumesThePendingConnectionAttempt() async throws { let host = Host(name: "Server", host: "server.example") let store = HostLibraryModel(hosts: [host]) @@ -23,7 +121,7 @@ struct SSHHostKeyAuthorizationTests { ) let decision = Task { - await controller.authorizeHostKey(challenge, using: store) + await controller.authorizeHostKey(challenge) } await waitForPrompt(controller) @@ -52,7 +150,7 @@ struct SSHHostKeyAuthorizationTests { ) let decision = Task { - await controller.authorizeHostKey(challenge, using: store) + await controller.authorizeHostKey(challenge) } await waitForPrompt(controller) controller.trustHostKey(observation, using: store) @@ -63,7 +161,6 @@ struct SSHHostKeyAuthorizationTests { @Test func disconnectRejectsAndClearsAPendingDecision() async throws { let host = Host(name: "Server", host: "server.example") - let store = HostLibraryModel(hosts: [host]) let controller = RemoteSessionConnectionController() controller.journey.begin( host: host, route: [host], purpose: .sshSession, @@ -79,8 +176,7 @@ struct SSHHostKeyAuthorizationTests { SSHHostKeyChallenge( hostID: host.id, observation: observation, expectedHostKeys: [] - ), - using: store + ) ) } await waitForPrompt(controller) @@ -99,4 +195,19 @@ struct SSHHostKeyAuthorizationTests { } #expect(controller.pendingHostKey != nil) } + + private func constrainedObservation() -> SSHHostKeyObservation { + SSHHostKeyObservation( + hostname: "server.example", port: 22, algorithm: .rsa, + fingerprint: "SHA256:new", legacyMD5Fingerprint: Data(0..<16)) + } + + private func matchingConstraint() -> SSHHostKeyConstraint { + SSHHostKeyConstraint(algorithm: .rsa, legacyMD5: Data(0..<16)) + } +} + +private actor HostKeyAuthorizerRecorder { + private(set) var challenges: [SSHHostKeyChallenge] = [] + func record(_ challenge: SSHHostKeyChallenge) { challenges.append(challenge) } } diff --git a/Frameworks/TermindApplication/Connections/RemoteConnectionURL.swift b/Frameworks/TermindApplication/Connections/RemoteConnectionURL.swift index 08d38b32..32591a7a 100644 --- a/Frameworks/TermindApplication/Connections/RemoteConnectionURL.swift +++ b/Frameworks/TermindApplication/Connections/RemoteConnectionURL.swift @@ -1,6 +1,21 @@ import Foundation import TermindDomain +import struct TermindDomain.Host + +public nonisolated enum RemoteConnectionOrigin: Equatable, Sendable { + case library + case externalLink(hostKeyConstraints: [Host.ID: SSHHostKeyConstraint]) + + public var allowsPersistence: Bool { + if case .library = self { true } else { false } + } + + public var hostKeyConstraints: [Host.ID: SSHHostKeyConstraint] { + if case .externalLink(let constraints) = self { constraints } else { [:] } + } +} + public nonisolated struct RemoteConnectionURL: Equatable, Sendable { public enum Kind: String, Equatable, Sendable { case ssh @@ -28,11 +43,11 @@ public nonisolated struct RemoteConnectionURL: Equatable, Sendable { public var port: Int public var path: String? public var sftpType: SFTPType? - public var hostKeyFingerprint: SSHURIHostKeyFingerprint? + public var hostKeyFingerprint: SSHHostKeyConstraint? public init( kind: Kind, username: String?, advisoryPassword: String?, host: String, port: Int, - path: String?, sftpType: SFTPType?, hostKeyFingerprint: SSHURIHostKeyFingerprint? + path: String?, sftpType: SFTPType?, hostKeyFingerprint: SSHHostKeyConstraint? ) { self.kind = kind self.username = username @@ -45,33 +60,6 @@ public nonisolated struct RemoteConnectionURL: Equatable, Sendable { } } -public nonisolated struct SSHURIHostKeyFingerprint: Equatable, Sendable { - public enum Algorithm: String, Equatable, Sendable { - case rsa = "ssh-rsa" - case dsa = "ssh-dss" - } - - public var algorithm: Algorithm - public var md5: Data - - public init(algorithm: Algorithm, md5: Data) { - self.algorithm = algorithm - self.md5 = md5 - } - - public func matches(_ observation: SSHHostKeyObservation) -> Bool { - let algorithmMatches = - switch algorithm { - case .rsa: observation.algorithm == .rsa - case .dsa: false // The current transport deliberately does not negotiate ssh-dss. - } - guard algorithmMatches, let candidate = observation.legacyMD5Fingerprint, - candidate.count == md5.count - else { return false } - return zip(candidate, md5).reduce(UInt8(0)) { $0 | ($1.0 ^ $1.1) } == 0 - } -} - public nonisolated enum RemoteConnectionURLParseError: LocalizedError, Equatable, Sendable { case unsupportedScheme case invalidURL @@ -134,7 +122,7 @@ public nonisolated enum RemoteConnectionURLParser { private static func parseCredentials( _ components: URLComponents, kind: RemoteConnectionURL.Kind - ) throws -> (username: String?, password: String?, fingerprint: SSHURIHostKeyFingerprint?) { + ) throws -> (username: String?, password: String?, fingerprint: SSHHostKeyConstraint?) { let password = try decode(components.percentEncodedPassword) if kind != .telnet, password != nil { throw RemoteConnectionURLParseError.invalidCredentials } @@ -148,7 +136,7 @@ public nonisolated enum RemoteConnectionURLParser { let username = try nonempty(decode(String(pieces.first ?? ""))) guard pieces.count == 2 else { return (username, nil, nil) } - var fingerprint: SSHURIHostKeyFingerprint? + var fingerprint: SSHHostKeyConstraint? for parameter in pieces[1].split(separator: ",", omittingEmptySubsequences: false) { let pair = parameter.split(separator: "=", maxSplits: 1, omittingEmptySubsequences: false) guard pair.count == 2, !pair[0].isEmpty else { @@ -198,8 +186,8 @@ public nonisolated enum RemoteConnectionURLParser { } } - private static func parseFingerprint(_ value: String) throws -> SSHURIHostKeyFingerprint { - let algorithm: SSHURIHostKeyFingerprint.Algorithm + private static func parseFingerprint(_ value: String) throws -> SSHHostKeyConstraint { + let algorithm: SSHHostKeyConstraint.Algorithm let digest: Substring if value.hasPrefix("ssh-rsa-") { algorithm = .rsa @@ -220,7 +208,7 @@ public nonisolated enum RemoteConnectionURLParser { } bytes.append(byte) } - return SSHURIHostKeyFingerprint(algorithm: algorithm, md5: Data(bytes)) + return SSHHostKeyConstraint(algorithm: algorithm, legacyMD5: Data(bytes)) } private static func decode(_ value: String?) throws -> String? { diff --git a/Frameworks/TermindApplication/Connections/RemoteSessionConnectionController.swift b/Frameworks/TermindApplication/Connections/RemoteSessionConnectionController.swift index fdde254b..7a0c7ccd 100644 --- a/Frameworks/TermindApplication/Connections/RemoteSessionConnectionController.swift +++ b/Frameworks/TermindApplication/Connections/RemoteSessionConnectionController.swift @@ -3,6 +3,102 @@ 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? + + 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. @@ -10,15 +106,16 @@ import struct TermindDomain.Host public final class RemoteSessionConnectionController { public let journey = ConnectionJourney() public let keyboardAuthentication = SSHKeyboardInteractiveQueue() + public let hostKeyAuthorization = SSHHostKeyAuthorizationCoordinator() private var keyboardAuthenticationID: UUID? public let reconnect: SessionReconnectController - public private(set) var pendingHostKey: SSHHostKeyPrompt? - public var temporaryHostKeys: [Host.ID: SSHHostKeyIdentity] = [:] + 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 - private var pendingHostKeyHostID: Host.ID? - private var hostKeyAuthorizationID: UUID? - private var hostKeyAuthorization: CheckedContinuation? public var reconnectState: ReconnectState? { reconnect.state } @@ -29,8 +126,7 @@ public final class RemoteSessionConnectionController { public func disconnect() { keyboardAuthenticationID = nil keyboardAuthentication.cancelAll() - finishHostKeyAuthorization(.reject) - temporaryHostKeys.removeAll() + hostKeyAuthorization.reset() reconnect.stop() hasConnectedOnce = false isRecovering = false @@ -84,29 +180,17 @@ public final class RemoteSessionConnectionController { public func trustHostKey( _ observation: SSHHostKeyObservation, using store: any RemoteSessionHostStore ) { - guard pendingHostKey?.observation == observation else { return } - store.trust( - KnownHostEntry( - hostname: observation.hostname, port: observation.port, - algorithm: observation.algorithm, fingerprint: observation.fingerprint, - addedAt: Date() - )) - if let pendingHostKeyHostID { temporaryHostKeys[pendingHostKeyHostID] = nil } - finishHostKeyAuthorization(.accept) + guard hostKeyAuthorization.trust(observation, using: store) else { return } journey.resume() } public func useTemporaryHostKey(_ observation: SSHHostKeyObservation) -> Bool { - guard pendingHostKey?.observation == observation, - let pendingHostKeyHostID - else { return false } - temporaryHostKeys[pendingHostKeyHostID] = observation.identity - finishHostKeyAuthorization(.accept) + guard hostKeyAuthorization.continueOnce(observation) else { return false } journey.resume() return true } - public func rejectHostKey() { finishHostKeyAuthorization(.reject) } + public func rejectHostKey() { hostKeyAuthorization.reject() } public func answerKeyboardInteractive(_ challenge: SSHKeyboardInteractiveChallenge) async throws -> [String] { try Task.checkCancellation() @@ -122,67 +206,15 @@ public final class RemoteSessionConnectionController { return try await keyboardAuthentication.request(challenge) } - public func authorizeHostKey( - _ challenge: SSHHostKeyChallenge, - using store: any RemoteSessionHostStore, - uriFingerprintMatched: Bool = false - ) async -> SSHHostKeyDecision { - if challenge.expectedHostKeys.contains(challenge.observation.identity) { - return .accept - } - finishHostKeyAuthorization(.reject) - let authorizationID = UUID() - hostKeyAuthorizationID = authorizationID - waitForHostKey( - SSHHostKeyPrompt( - observation: challenge.observation, - kind: challenge.expectedHostKeys.isEmpty - ? .firstUse - : .additionalKey(trustedKeyCount: challenge.expectedHostKeys.count), - uriFingerprintMatched: uriFingerprintMatched - ), - hostID: challenge.hostID, - fingerprint: challenge.observation.fingerprint - ) - return await withTaskCancellationHandler { - await withCheckedContinuation { continuation in - guard hostKeyAuthorizationID == authorizationID else { - continuation.resume(returning: .reject) - return - } - hostKeyAuthorization = continuation - } - } onCancel: { - Task { @MainActor [weak self] in - self?.cancelHostKeyAuthorization(id: authorizationID) - } + 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 authorizeHostKey( - _ challenge: SSHHostKeyChallenge, - using store: any RemoteSessionHostStore, - uriFingerprint: SSHURIHostKeyFingerprint?, - destinationHostID: Host.ID - ) async -> SSHHostKeyDecision { - guard challenge.hostID == destinationHostID, let uriFingerprint else { - return await authorizeHostKey(challenge, using: store) - } - guard uriFingerprint.matches(challenge.observation) else { return .reject } - if !challenge.expectedHostKeys.isEmpty { - return challenge.expectedHostKeys.contains(challenge.observation.identity) ? .accept : .reject - } - return await authorizeHostKey( - challenge, using: store, uriFingerprintMatched: true) - } - - public func waitForHostKey( - _ prompt: SSHHostKeyPrompt, hostID: Host.ID, fingerprint: String - ) { - pendingHostKey = prompt - pendingHostKeyHostID = hostID + public func waitForHostKey(_ prompt: SSHHostKeyPrompt, hostID: Host.ID) { reconnect.pauseForUserInteraction() - journey.waitForHostKey(at: hostID, fingerprint: fingerprint) + journey.waitForHostKey(at: hostID, fingerprint: prompt.observation.fingerprint) } public func progressHandler( @@ -208,20 +240,6 @@ public final class RemoteSessionConnectionController { } public func clearHostKeyPrompt() { - finishHostKeyAuthorization(.reject) - } - - private func cancelHostKeyAuthorization(id: UUID) { - guard hostKeyAuthorizationID == id else { return } - finishHostKeyAuthorization(.reject) - } - - private func finishHostKeyAuthorization(_ decision: SSHHostKeyDecision) { - let continuation = hostKeyAuthorization - hostKeyAuthorization = nil - hostKeyAuthorizationID = nil - pendingHostKey = nil - pendingHostKeyHostID = nil - continuation?.resume(returning: decision) + hostKeyAuthorization.cancelPending() } } diff --git a/Frameworks/TermindApplication/Connections/SSHAuthenticationPrompt.swift b/Frameworks/TermindApplication/Connections/SSHAuthenticationPrompt.swift index e41677fa..6a62a66e 100644 --- a/Frameworks/TermindApplication/Connections/SSHAuthenticationPrompt.swift +++ b/Frameworks/TermindApplication/Connections/SSHAuthenticationPrompt.swift @@ -48,3 +48,61 @@ public nonisolated struct SSHAuthenticationPrompt: Identifiable, Hashable, Senda } } + +/// Shared projection from route metadata and staged submissions into the +/// presentation-only authentication prompt. Connection lifetimes, retries, +/// and credential persistence remain owned by each feature. +@MainActor +public enum SSHAuthenticationPromptResolver { + public static func host( + _ host: Host, using store: any RemoteSessionHostStore, + submission: SSHAuthenticationSubmission? = nil, + username: String? = nil, selectedKeyID: SSHKeyRef.ID? = nil, + failureMessage: String? = nil, + mode: SSHAuthenticationPrompt.Mode = .localIdentity, + requiresPassphrase: Bool? = nil, allowsSaving: Bool = true + ) -> SSHAuthenticationPrompt { + let identity = submission?.identityID.flatMap(store.identity) ?? store.identity(for: host) + let keyID = selectedKeyID ?? submission?.keyID ?? identity?.keyID + let resolvedUsername = username ?? submission?.username ?? identity?.username ?? "" + let needsPassphrase = + requiresPassphrase + ?? (submission?.requiresPasswordReentry != true + && keyID.flatMap(store.key)?.requiresPassphraseEntry == true + && submission?.passphrase == nil) + return SSHAuthenticationPrompt( + host: host, selectedIdentityID: submission?.identityID ?? identity?.id, + username: resolvedUsername, selectedKeyID: keyID, + failureMessage: failureMessage?.isEmpty == false ? failureMessage : nil, + requiresPassphrase: needsPassphrase, mode: mode, + allowsSaving: allowsSaving) + } + + public static func proxy( + for owner: Host, using store: any RemoteSessionHostStore, + submission: SSHProxyAuthenticationSubmission? = nil, + failureMessage: String? = nil, + mode: SSHAuthenticationPrompt.Mode = .localIdentity, + allowsSaving: Bool = true + ) -> SSHAuthenticationPrompt? { + guard let proxy = owner.proxy else { return nil } + let configured = (submission?.identityID ?? proxy.identityID).flatMap(store.identity) + let selected: Identity? + if let configured { + switch store.passwordAvailability(for: configured) { + case .available, .notConfigured: selected = configured + case .missing, .failed: selected = nil + } + } else { + selected = nil + } + return SSHAuthenticationPrompt( + host: Host( + id: owner.id, name: String(localized: "Proxy for \(owner.name)"), + host: proxy.hostname, port: proxy.port), + selectedIdentityID: selected?.id, + username: submission?.username ?? proxy.username, + failureMessage: failureMessage, mode: mode, + target: .proxy(ownerHostID: owner.id), allowsSaving: allowsSaving) + } +} diff --git a/Frameworks/TermindApplication/Metrics/ServerMetricsModel.swift b/Frameworks/TermindApplication/Metrics/ServerMetricsModel.swift index daee3c6a..00392aa8 100644 --- a/Frameworks/TermindApplication/Metrics/ServerMetricsModel.swift +++ b/Frameworks/TermindApplication/Metrics/ServerMetricsModel.swift @@ -19,13 +19,14 @@ public final class ServerMetric: Identifiable { public var gpuSnapshot: NvidiaSnapshot? public var gpuDate: Date? public var authenticationHost: Host? - public var hostKey: SSHHostKeyObservation? + public let hostKeyAuthorization = SSHHostKeyAuthorizationCoordinator() + public var hostKey: SSHHostKeyObservation? { hostKeyAuthorization.pending?.prompt.observation } public var needsAttention = false public let journey = ConnectionJourney() public let keyboardAuthentication = SSHKeyboardInteractiveQueue() public var authenticationPrompt: SSHAuthenticationPrompt? - public var hostKeyPrompt: SSHHostKeyPrompt? - public var hostKeyHostID: UUID? + public var hostKeyPrompt: SSHHostKeyPrompt? { hostKeyAuthorization.pending?.prompt } + public var hostKeyHostID: UUID? { hostKeyAuthorization.pending?.hostID } public var requiresAuthentication: Bool { hostKey != nil || authenticationPrompt != nil || keyboardAuthentication.current != nil } @@ -69,12 +70,7 @@ public final class ServerMetricsModel: MetricsSnapshotReading { private var proxySubmissions: [UUID: [UUID: SSHProxyAuthenticationSubmission]] = [:] private var maintenance: Task? private var detailObservers: [UUID: UUID] = [:] - private var temporaryKeys: [UUID: [UUID: SSHHostKeyIdentity]] = [:] private var stamps: [UUID: Data] = [:] - private struct PendingHostKeyAuthorization { - var id: UUID - var continuation: AsyncStream.Continuation - } private struct PendingKeyboardAuthentication { var id: UUID var task: Task<[String], any Error> @@ -84,14 +80,9 @@ public final class ServerMetricsModel: MetricsSnapshotReading { var preparation: SSHConnectionPreparation? var sampler: any MetricsSampling } - private nonisolated struct HostKeyAuthorization: Sendable { - var id: UUID - var decisions: AsyncStream - } private nonisolated enum RunCycleDisposition: Sendable { case stop, attention, retry } - private var pendingHostKeyAuthorizations: [UUID: PendingHostKeyAuthorization] = [:] private var pendingKeyboardAuthentications: [UUID: PendingKeyboardAuthentication] = [:] public var keepsRunning: Bool { servers.contains { $0.selection.enabled } } @@ -230,11 +221,10 @@ public final class ServerMetricsModel: MetricsSnapshotReading { public func retry(_ server: ServerMetric) { server.journey.resume() server.authenticationPrompt = nil - server.hostKeyPrompt = nil + server.hostKeyAuthorization.cancelPending() cancel(server.id) server.isConnecting = false server.authenticationHost = nil - server.hostKey = nil server.needsAttention = false launch(server) } @@ -314,22 +304,13 @@ public final class ServerMetricsModel: MetricsSnapshotReading { } public func acceptHostKey(_ server: ServerMetric, observation: SSHHostKeyObservation, remember: Bool) { - guard server.hostKey == observation, let hostID = server.hostKeyHostID else { return } - if remember { - hosts.trust( - KnownHostEntry( - hostname: observation.hostname, port: observation.port, - algorithm: observation.algorithm, fingerprint: observation.fingerprint, addedAt: clock.now() - )) - } else { - temporaryKeys[server.id, default: [:]][hostID] = observation.identity - } - server.hostKey = nil - server.hostKeyHostID = nil - server.hostKeyPrompt = nil + let accepted = + remember + ? server.hostKeyAuthorization.trust(observation, using: hosts, addedAt: clock.now()) + : server.hostKeyAuthorization.continueOnce(observation) + guard accepted else { return } server.needsAttention = false server.journey.resume() - finishHostKeyAuthorization(for: server.id, decision: .accept) } private func preparation( @@ -341,34 +322,19 @@ public final class ServerMetricsModel: MetricsSnapshotReading { for: host, submissions: submissions, proxySubmissions: proxySubmissions, - temporaryHostKeys: temporaryKeys[server.id] ?? [:] + temporaryHostKeys: server.hostKeyAuthorization.temporaryHostKeys ) } private func requestAuthentication(_ server: ServerMetric, hostID: UUID, error: TermindSSHError) { guard let host = hosts.host(hostID) else { return } - if error.isProxyAuthentication, let proxy = host.proxy { + if error.isProxyAuthentication, host.proxy != nil { let staged = proxySubmissions[server.id]?[host.id] - let configured = (staged?.identityID ?? proxy.identityID).flatMap { hosts.identity($0) } - let selected: Identity? - if let configured { - switch hosts.passwordAvailability(for: configured) { - case .available, .notConfigured: selected = configured - case .missing, .failed: selected = nil - } - } else { - selected = nil - } server.authenticationHost = host - server.authenticationPrompt = SSHAuthenticationPrompt( - host: Host( - id: host.id, name: String(localized: "Proxy for \(host.name)"), - host: proxy.hostname, port: proxy.port), - selectedIdentityID: selected?.id, - username: staged?.username ?? proxy.username, + server.authenticationPrompt = SSHAuthenticationPromptResolver.proxy( + for: host, using: hosts, submission: staged, failureMessage: error.localizedDescription, - mode: error.isServerAuthenticationChallenge ? .serverChallenge : .localIdentity, - target: .proxy(ownerHostID: host.id)) + mode: error.isServerAuthenticationChallenge ? .serverChallenge : .localIdentity) server.needsAttention = true server.journey.waitForProxyAuthentication(at: host.id, failure: error.localizedDescription) return @@ -381,11 +347,11 @@ public final class ServerMetricsModel: MetricsSnapshotReading { && keyID.flatMap { hosts.key($0) }?.requiresPassphraseEntry == true && staged?.passphrase == nil server.authenticationHost = host - server.authenticationPrompt = SSHAuthenticationPrompt( - host: host, selectedIdentityID: identity?.id, username: staged?.username ?? identity?.username ?? "", - selectedKeyID: keyID, failureMessage: error.localizedDescription, requiresPassphrase: passphrase, - mode: error.isServerAuthenticationChallenge ? .serverChallenge : .localIdentity - ) + server.authenticationPrompt = SSHAuthenticationPromptResolver.host( + host, using: hosts, submission: staged, + failureMessage: error.localizedDescription, + mode: error.isServerAuthenticationChallenge ? .serverChallenge : .localIdentity, + requiresPassphrase: passphrase) server.needsAttention = true if error.isServerAuthenticationChallenge { server.journey.waitForAuthentication(at: host.id, failure: error.localizedDescription) @@ -420,7 +386,7 @@ public final class ServerMetricsModel: MetricsSnapshotReading { maintenance = nil for server in servers { cancel(server.id) } submissions = [:] - temporaryKeys = [:] + for server in servers { server.hostKeyAuthorization.reset() } for server in servers { server.journey.hide() } } @@ -441,7 +407,7 @@ public final class ServerMetricsModel: MetricsSnapshotReading { if let old = stamps[server.id], old != stamp { submissions[server.id] = nil proxySubmissions[server.id] = nil - temporaryKeys[server.id] = nil + server.hostKeyAuthorization.reset() retry(server) } stamps[server.id] = stamp @@ -500,9 +466,8 @@ public final class ServerMetricsModel: MetricsSnapshotReading { cancel(server.id) server.isConnecting = false server.authenticationHost = nil - server.hostKey = nil server.authenticationPrompt = nil - server.hostKeyPrompt = nil + server.hostKeyAuthorization.cancelPending() server.needsAttention = false if devicesChanged { server.history = [] @@ -511,7 +476,7 @@ public final class ServerMetricsModel: MetricsSnapshotReading { if !selection.enabled { submissions[server.id] = nil proxySubmissions[server.id] = nil - temporaryKeys[server.id] = nil + server.hostKeyAuthorization.reset() server.journey.hide() } } @@ -526,7 +491,7 @@ public final class ServerMetricsModel: MetricsSnapshotReading { if !configuredIDs.contains(server.id) { submissions[server.id] = nil proxySubmissions[server.id] = nil - temporaryKeys[server.id] = nil + server.hostKeyAuthorization.reset() stamps[server.id] = nil detailObservers = detailObservers.filter { $0.value != server.id } } @@ -543,15 +508,12 @@ public final class ServerMetricsModel: MetricsSnapshotReading { private func cancel(_ id: UUID) { generations[id] = nil workers.removeValue(forKey: id)?.cancel() - let hadHostKeyAuthorization = pendingHostKeyAuthorizations[id] != nil - finishHostKeyAuthorization(for: id, decision: .reject) + let hadHostKeyAuthorization = servers.first(where: { $0.id == id })?.hostKeyPrompt != nil let authentication = pendingKeyboardAuthentications.removeValue(forKey: id) authentication?.task.cancel() if let server = servers.first(where: { $0.id == id }) { if hadHostKeyAuthorization { - server.hostKey = nil - server.hostKeyHostID = nil - server.hostKeyPrompt = nil + server.hostKeyAuthorization.cancelPending() } if hadHostKeyAuthorization || authentication != nil { server.needsAttention = false } server.keyboardAuthentication.cancelAll() @@ -951,61 +913,24 @@ public final class ServerMetricsModel: MetricsSnapshotReading { private nonisolated func authorizeHostKeyFromWorker( _ serverID: UUID, challenge: SSHHostKeyChallenge, token: UUID ) async -> SSHHostKeyDecision { - if challenge.expectedHostKeys.contains(challenge.observation.identity) { - return .accept - } - guard let authorization = await beginHostKeyAuthorization(serverID, challenge: challenge, token: token) else { - return .reject - } - return await withTaskCancellationHandler { - for await decision in authorization.decisions { return decision } - return .reject - } onCancel: { - Task.detached { [weak self] in - await self?.cancelHostKeyAuthorization(for: serverID, id: authorization.id) - } - } + guard await isCurrent(serverID, token: token) else { return .reject } + return await authorizeHostKeyOnMainActor(serverID, challenge: challenge, token: token) } - private func beginHostKeyAuthorization( + private func authorizeHostKeyOnMainActor( _ serverID: UUID, challenge: SSHHostKeyChallenge, token: UUID - ) -> HostKeyAuthorization? { - guard current(serverID, token), let server = servers.first(where: { $0.id == serverID }) else { return nil } - finishHostKeyAuthorization(for: serverID, decision: .reject) - server.hostKey = challenge.observation - server.hostKeyHostID = challenge.hostID - server.hostKeyPrompt = SSHHostKeyPrompt( - observation: challenge.observation, - kind: challenge.expectedHostKeys.isEmpty - ? .firstUse - : .additionalKey(trustedKeyCount: challenge.expectedHostKeys.count) - ) - server.status = String(localized: "Server Identity Verification Required") - server.needsAttention = true - server.journey.waitForHostKey( - at: challenge.hostID, - fingerprint: challenge.observation.fingerprint - ) - let authorizationID = UUID() - let decisions = AsyncStream { continuation in - pendingHostKeyAuthorizations[serverID] = PendingHostKeyAuthorization( - id: authorizationID, continuation: continuation - ) + ) async -> SSHHostKeyDecision { + guard current(serverID, token), let server = servers.first(where: { $0.id == serverID }) else { + return .reject } - return HostKeyAuthorization(id: authorizationID, decisions: decisions) - } - - private func cancelHostKeyAuthorization(for serverID: UUID, id: UUID) { - guard pendingHostKeyAuthorizations[serverID]?.id == id else { return } - finishHostKeyAuthorization(for: serverID, decision: .reject) - } - - private func finishHostKeyAuthorization( - for serverID: UUID, decision: SSHHostKeyDecision - ) { - guard let pending = pendingHostKeyAuthorizations.removeValue(forKey: serverID) else { return } - pending.continuation.yield(decision) - pending.continuation.finish() + let decision = await server.hostKeyAuthorization.authorize(challenge) { request in + server.status = String(localized: "Server Identity Verification Required") + server.needsAttention = true + server.journey.waitForHostKey( + at: request.hostID, + fingerprint: request.prompt.observation.fingerprint) + } + return current(serverID, token) ? decision : .reject } } diff --git a/Frameworks/TermindApplication/SSHConnectionTypes.swift b/Frameworks/TermindApplication/SSHConnectionTypes.swift index b676e7f1..ee00c6ac 100644 --- a/Frameworks/TermindApplication/SSHConnectionTypes.swift +++ b/Frameworks/TermindApplication/SSHConnectionTypes.swift @@ -32,14 +32,49 @@ public nonisolated struct SSHHostKeyChallenge: Hashable, Sendable { public var hostID: Host.ID public var observation: SSHHostKeyObservation public var expectedHostKeys: Set + /// A provisional out-of-band constraint matched this key. Persistent + /// Known Hosts remains authoritative and the user must still approve + /// first use before the key can be trusted. + public var provisionalConstraintMatched: Bool public init( hostID: Host.ID, observation: SSHHostKeyObservation, - expectedHostKeys: Set + expectedHostKeys: Set, + provisionalConstraintMatched: Bool = false ) { self.hostID = hostID self.observation = observation self.expectedHostKeys = expectedHostKeys + self.provisionalConstraintMatched = provisionalConstraintMatched + } +} + +/// An additional, provisional host-key constraint supplied out of band. +/// It can reject a handshake, but never creates or overrides Known Hosts trust. +public nonisolated struct SSHHostKeyConstraint: Hashable, Sendable { + public enum Algorithm: String, Hashable, Sendable { + case rsa = "ssh-rsa" + case dsa = "ssh-dss" + } + + public var algorithm: Algorithm + public var legacyMD5: Data + + public init(algorithm: Algorithm, legacyMD5: Data) { + self.algorithm = algorithm + self.legacyMD5 = legacyMD5 + } + + public func matches(_ observation: SSHHostKeyObservation) -> Bool { + let algorithmMatches = + switch algorithm { + case .rsa: observation.algorithm == .rsa + case .dsa: false // The transport deliberately does not negotiate ssh-dss. + } + guard algorithmMatches, let candidate = observation.legacyMD5Fingerprint, + candidate.count == legacyMD5.count + else { return false } + return zip(candidate, legacyMD5).reduce(UInt8(0)) { $0 | ($1.0 ^ $1.1) } == 0 } } @@ -89,17 +124,20 @@ public nonisolated struct SSHConnectionRequest: Sendable { public var authorizeHostKey: SSHHostKeyAuthorizer? public var keyboardInteractive: SSHKeyboardInteractiveHandler? public var interactionPolicy: SSHInteractionPolicy + public var hostKeyConstraints: [Host.ID: SSHHostKeyConstraint] public init( configuration: SSHConnectionConfiguration, authorizeHostKey: SSHHostKeyAuthorizer? = nil, keyboardInteractive: SSHKeyboardInteractiveHandler? = nil, - interactionPolicy: SSHInteractionPolicy = .allowed + interactionPolicy: SSHInteractionPolicy = .allowed, + hostKeyConstraints: [Host.ID: SSHHostKeyConstraint] = [:] ) { self.configuration = configuration self.authorizeHostKey = authorizeHostKey self.keyboardInteractive = keyboardInteractive self.interactionPolicy = interactionPolicy + self.hostKeyConstraints = hostKeyConstraints } public func withKeyboardInteractiveFallback(_ handler: SSHKeyboardInteractiveHandler?) -> Self { @@ -236,6 +274,16 @@ public nonisolated struct SSHHostKeyPrompt: Hashable, Sendable { self.uriFingerprintMatched = uriFingerprintMatched } + public init(challenge: SSHHostKeyChallenge) { + self.init( + observation: challenge.observation, + kind: challenge.expectedHostKeys.isEmpty + ? .firstUse + : .additionalKey(trustedKeyCount: challenge.expectedHostKeys.count), + uriFingerprintMatched: challenge.provisionalConstraintMatched + ) + } + } public nonisolated enum SSHCredentials: Sendable { diff --git a/Frameworks/TermindPresentation/ConnectionDecisionForms.swift b/Frameworks/TermindPresentation/ConnectionDecisionForms.swift index 65f0930a..0943c0e8 100644 --- a/Frameworks/TermindPresentation/ConnectionDecisionForms.swift +++ b/Frameworks/TermindPresentation/ConnectionDecisionForms.swift @@ -497,9 +497,11 @@ public struct SSHHostKeyConfirmationForm: View { Text(observation.algorithm.displayName).font(.caption).foregroundStyle(.secondary) Text(observation.fingerprint).font(.body.monospaced()).textSelection(.enabled) if prompt.uriFingerprintMatched { - Label("The legacy fingerprint in the link matches this host key.", systemImage: "link.badge.checkmark") - .font(.caption) - .foregroundStyle(.secondary) + Label( + "The legacy fingerprint in the link matches this host key.", systemImage: "link.badge.checkmark" + ) + .font(.caption) + .foregroundStyle(.secondary) } } .padding(12) diff --git a/Frameworks/TermindRemoteInfrastructure/Agent/AgentConnections.swift b/Frameworks/TermindRemoteInfrastructure/Agent/AgentConnections.swift index 01952212..d41f4ed3 100644 --- a/Frameworks/TermindRemoteInfrastructure/Agent/AgentConnections.swift +++ b/Frameworks/TermindRemoteInfrastructure/Agent/AgentConnections.swift @@ -384,12 +384,7 @@ public final class AgentHostResolver: Sendable { AgentSSHHostKeyRequest( invocationID: invocationID, hostID: challenge.hostID, displayName: displayName, address: address, - prompt: SSHHostKeyPrompt( - observation: challenge.observation, - kind: challenge.expectedHostKeys.isEmpty - ? .firstUse - : .additionalKey(trustedKeyCount: challenge.expectedHostKeys.count) - ), + prompt: SSHHostKeyPrompt(challenge: challenge), allowsPersistentTrust: saved != nil ) ) diff --git a/Frameworks/TermindRemoteInfrastructure/SSH/PortForwardingController.swift b/Frameworks/TermindRemoteInfrastructure/SSH/PortForwardingController.swift index 39633a2e..e0fba2e8 100644 --- a/Frameworks/TermindRemoteInfrastructure/SSH/PortForwardingController.swift +++ b/Frameworks/TermindRemoteInfrastructure/SSH/PortForwardingController.swift @@ -510,7 +510,7 @@ public final class PortForwardingController { if context.hostKeyRequestID == requestID { context.hostKeyRequestID = nil } } update(context, state: .awaitingTrust) - let decision = await context.connection.authorizeHostKey(challenge, using: hosts) + let decision = await context.connection.authorizeHostKey(challenge) guard isCurrent(context, attempt: attempt), !Task.isCancelled else { return .reject } update(context, state: .connecting) return decision @@ -533,28 +533,13 @@ public final class PortForwardingController { fail(context, message: error.localizedDescription) return } - if error.isProxyAuthentication, let proxy = host.proxy { + if error.isProxyAuthentication, host.proxy != nil { let staged = context.proxySubmissions.removeValue(forKey: host.id) - let configured = (staged?.identityID ?? proxy.identityID).flatMap(hosts.identity) - let selected: Identity? - if let configured { - switch hosts.passwordAvailability(for: configured) { - case .available, .notConfigured: selected = configured - case .missing, .failed: selected = nil - } - } else { - selected = nil - } context.setPrompt( - SSHAuthenticationPrompt( - host: Host( - id: host.id, name: String(localized: "Proxy for \(host.name)"), - host: proxy.hostname, port: proxy.port), - selectedIdentityID: selected?.id, - username: staged?.username ?? proxy.username, + SSHAuthenticationPromptResolver.proxy( + for: host, using: hosts, submission: staged, failureMessage: error.localizedDescription, - mode: error.isServerAuthenticationChallenge ? .serverChallenge : .localIdentity, - target: .proxy(ownerHostID: host.id))) + mode: error.isServerAuthenticationChallenge ? .serverChallenge : .localIdentity)) context.journey.waitForProxyAuthentication(at: host.id, failure: error.localizedDescription) update(context, state: .awaitingAuthentication) return @@ -567,10 +552,11 @@ public final class PortForwardingController { && keyID.flatMap(hosts.key)?.requiresPassphraseEntry == true && staged?.passphrase == nil context.setPrompt( - SSHAuthenticationPrompt( - host: host, selectedIdentityID: identity?.id, username: staged?.username ?? identity?.username ?? "", - selectedKeyID: keyID, failureMessage: error.localizedDescription, requiresPassphrase: passphrase, - mode: error.isServerAuthenticationChallenge ? .serverChallenge : .localIdentity)) + SSHAuthenticationPromptResolver.host( + host, using: hosts, submission: staged, + failureMessage: error.localizedDescription, + mode: error.isServerAuthenticationChallenge ? .serverChallenge : .localIdentity, + requiresPassphrase: passphrase)) if error.isServerAuthenticationChallenge { context.journey.waitForAuthentication(at: host.id, failure: error.localizedDescription) } else if passphrase { diff --git a/Frameworks/TermindRemoteInfrastructure/SSH/SSHConnectionOpener.swift b/Frameworks/TermindRemoteInfrastructure/SSH/SSHConnectionOpener.swift index 3103b315..ebdc0962 100644 --- a/Frameworks/TermindRemoteInfrastructure/SSH/SSHConnectionOpener.swift +++ b/Frameworks/TermindRemoteInfrastructure/SSH/SSHConnectionOpener.swift @@ -89,10 +89,12 @@ public nonisolated enum SSHConnectionOpener { public static func authorizeHostKey( hostID: TermindDomain.Host.ID, observed: SSHHostKeyObservation, - expected: Set, authorizer: SSHHostKeyAuthorizer? + expected: Set, constraint: SSHHostKeyConstraint? = nil, + authorizer: SSHHostKeyAuthorizer? ) async throws { try await SSHPTYChannelWorker.authorizeHostKey( - hostID: hostID, observed: observed, expected: expected, authorizer: authorizer) + hostID: hostID, observed: observed, expected: expected, + constraint: constraint, authorizer: authorizer) } public static func performHandshake( diff --git a/Frameworks/TermindRemoteInfrastructure/SSH/SSHPTYChannel.swift b/Frameworks/TermindRemoteInfrastructure/SSH/SSHPTYChannel.swift index 46625976..6448d8fe 100644 --- a/Frameworks/TermindRemoteInfrastructure/SSH/SSHPTYChannel.swift +++ b/Frameworks/TermindRemoteInfrastructure/SSH/SSHPTYChannel.swift @@ -932,7 +932,8 @@ nonisolated final class SSHPTYChannelWorker: @unchecked Sendable { proxy: configuration.proxy, jumps: configuration.jumps, timeout: timeout, progress: progress, authorizeHostKey: request.authorizeHostKey, - keyboardInteractive: request.keyboardInteractive + keyboardInteractive: request.keyboardInteractive, + hostKeyConstraints: request.hostKeyConstraints ) let queue = DispatchQueue(label: "com.akinokaede.termind.ssh.authenticated-opening") let exchange = SSHKeyboardInteractiveExchange( @@ -950,6 +951,7 @@ nonisolated final class SSHPTYChannelWorker: @unchecked Sendable { try await authorizeHostKey( hostID: configuration.hostID, observed: observed, expected: configuration.expectedHostKeys, + constraint: request.hostKeyConstraints[configuration.hostID], authorizer: request.authorizeHostKey ) try Task.checkCancellation() @@ -1766,19 +1768,31 @@ nonisolated final class SSHPTYChannelWorker: @unchecked Sendable { public static func authorizeHostKey( hostID: Host.ID, observed: SSHHostKeyObservation, - expected: Set, authorizer: SSHHostKeyAuthorizer? + expected: Set, constraint: SSHHostKeyConstraint? = nil, + authorizer: SSHHostKeyAuthorizer? ) async throws { + let constraintMatched: Bool + if let constraint { + guard constraint.matches(observed) else { throw TermindSSHError.mismatchedHostKey } + constraintMatched = true + } else { + constraintMatched = false + } + if expected.contains(observed.identity) { return } + // A provisional fingerprint is an additional constraint, never an + // override for an existing, conflicting Known Hosts record. + if constraintMatched, !expected.isEmpty { throw TermindSSHError.mismatchedHostKey } if let authorizer { let decision = try await authorizer( SSHHostKeyChallenge( hostID: hostID, observation: observed, - expectedHostKeys: expected + expectedHostKeys: expected, + provisionalConstraintMatched: constraintMatched ) ) guard decision == .accept else { throw CancellationError() } return } - if expected.contains(observed.identity) { return } if expected.isEmpty { throw TermindSSHError.untrustedHost } throw TermindSSHError.hostKeyChanged( expected: expected.map(\.fingerprint).sorted(), diff --git a/Frameworks/TermindRemoteInfrastructure/SSH/SSHRoutedSocket.swift b/Frameworks/TermindRemoteInfrastructure/SSH/SSHRoutedSocket.swift index 85aec3b1..93b3a566 100644 --- a/Frameworks/TermindRemoteInfrastructure/SSH/SSHRoutedSocket.swift +++ b/Frameworks/TermindRemoteInfrastructure/SSH/SSHRoutedSocket.swift @@ -116,7 +116,8 @@ public nonisolated final class SSHRoutedSocket: @unchecked Sendable { jumps: [SSHRouteHopConfiguration], timeout: TimeInterval, progress: SSHRouteProgressHandler? = nil, authorizeHostKey: SSHHostKeyAuthorizer?, - keyboardInteractive: SSHKeyboardInteractiveHandler? = nil + keyboardInteractive: SSHKeyboardInteractiveHandler? = nil, + hostKeyConstraints: [Host.ID: SSHHostKeyConstraint] = [:] ) async throws -> SSHRoutedSocket { let queue = DispatchQueue(label: "com.akinokaede.termind.ssh.route-opening") let state = SSHRouteOpeningState() @@ -151,6 +152,7 @@ public nonisolated final class SSHRoutedSocket: @unchecked Sendable { try await SSHConnectionOpener.authorizeHostKey( hostID: hop.id, observed: observed, expected: hop.expectedHostKeys, + constraint: hostKeyConstraints[hop.id], authorizer: authorizeHostKey ) try Task.checkCancellation() diff --git a/Memory/ARCHITECTURE.md b/Memory/ARCHITECTURE.md index bc6ee6db..d412d320 100644 --- a/Memory/ARCHITECTURE.md +++ b/Memory/ARCHITECTURE.md @@ -97,6 +97,11 @@ sibling working tree is not the app dependency. Follow `AGENTS.md` for package r - SSH/SFTP/mobile Workspaces/Metrics share preparation/recovery policy. Host-key approval occurs inside the actual socket/libssh2 handshake-to-auth attempt, never a discovery connection. Generations reject retired callbacks, credential results and timers. +- Host-key constraints are declarative request data enforced at the transport boundary before interactive approval. + A matching Known Hosts key bypasses the authorizer; a provisional External Link fingerprint may reject but never + establish or override persistent trust. Unknown keys still use the shared MainActor authorization coordinator for + one pending decision, cancellation, Continue Once and Trust and Continue; feature models retain their own Journey + and retry lifetimes. - Every resolved Password, software Private Key, Key Passphrase and proxy Password is one-shot per physical connection attempt. Runtime copies share one explicitly owned, non-copy-on-write allocation; source `Data`, proxy frames and the allocation are overwritten before release. The transport clears it after the authentication call and every success, diff --git a/Memory/REFACTOR.md b/Memory/REFACTOR.md index 00f62f06..d8089400 100644 --- a/Memory/REFACTOR.md +++ b/Memory/REFACTOR.md @@ -250,3 +250,17 @@ never justifies extraction. Keep schemas, native lists/terminal teardown and loc two opt-in window skips. Default macOS: 1,858 passed, 65 opt-in skips, three unrelated Agent failures. Two timing failures passed on immediate 41-test rerun; deterministic `list_hosts` summary expectation remained. Recursive format retained `PortForwardingView.swift:197`. + +### 2026-09-22 — shared SSH host-key verification and interaction (complete) + +- **Change:** separated declarative Host-key constraints from interactive approval at the transport boundary. Known Hosts + matches bypass feature authorizers; External Link MD5 fingerprints can reject but cannot establish or override trust, and + matched first-use links still show the SHA-256 confirmation. One MainActor coordinator now owns pending decisions, + cancellation and temporary/persistent trust for sessions, SFTP, forwarding and Metrics; a shared resolver projects Host, + proxy and passphrase prompts while feature retry/Journey lifetimes remain separate. Typed connection origins replace the + external-link boolean/fingerprint pair on SSH/SFTP/mobile workspaces. Agent interaction queues remain distinct and AgentKit + is unchanged. Production +396/−422 lines, net −26. +- **Verification:** TFM and generic iOS Simulator builds passed; focused Host-key/Metrics/External Link/Snapshot suites and the + 23-test MCP upload rerun passed. The default macOS run reached 1,430 passed/65 skipped before unrelated StoreKit sandbox, + MCP timing and pre-existing Agent summary failures; MCP passed immediately on focused rerun and the stabilized Metrics + snapshot suite passed 2/2. Recursive strict format, diff and Chinese localization checks passed. diff --git a/TFI/App/MobileAppModel.swift b/TFI/App/MobileAppModel.swift index 5b66474b..3cd56767 100644 --- a/TFI/App/MobileAppModel.swift +++ b/TFI/App/MobileAppModel.swift @@ -333,11 +333,11 @@ final class MobileAppModel { transientHost: host, connectionRoute: request.kind == .telnet ? nil : route, usernameHint: request.username, - uriHostKeyFingerprint: request.hostKeyFingerprint, + connectionOrigin: .externalLink( + hostKeyConstraints: request.hostKeyFingerprint.map { [host.id: $0] } ?? [:]), advisoryTelnetPassword: request.advisoryPassword.map(SSHSecret.init), externalSFTPResourcePath: request.kind == .sftp ? request.path : nil, - externalSFTPMustBeDirectory: request.sftpType == .directory, - isExternalTemporaryConnection: true) + externalSFTPMustBeDirectory: request.sftpType == .directory) addWorkspaces([workspace], persists: false) return workspace.id } diff --git a/TFI/Features/Workspaces/MobileWorkspace.swift b/TFI/Features/Workspaces/MobileWorkspace.swift index ab743026..c98854f6 100644 --- a/TFI/Features/Workspaces/MobileWorkspace.swift +++ b/TFI/Features/Workspaces/MobileWorkspace.swift @@ -118,10 +118,10 @@ final class MobileWorkspace: Identifiable { private var transientHost: Host? private var connectionRoute: SSHConnectionRoute? private var usernameHint: String? - private var uriHostKeyFingerprint: SSHURIHostKeyFingerprint? + private let connectionOrigin: RemoteConnectionOrigin + var isExternalTemporaryConnection: Bool { !connectionOrigin.allowsPersistence } private var advisoryTelnetPassword: SSHSecret? private(set) var pendingExternalPreviewURL: URL? - let isExternalTemporaryConnection: Bool var pendingInitialCommand: String? { pendingCommand?.command } private(set) var workingDirectory: String? var commandGeneratorState: CommandGeneratorState { commandGeneration.state } @@ -231,11 +231,10 @@ final class MobileWorkspace: Identifiable { transientHost: Host? = nil, connectionRoute: SSHConnectionRoute? = nil, usernameHint: String? = nil, - uriHostKeyFingerprint: SSHURIHostKeyFingerprint? = nil, + connectionOrigin: RemoteConnectionOrigin = .library, advisoryTelnetPassword: SSHSecret? = nil, externalSFTPResourcePath: String? = nil, - externalSFTPMustBeDirectory: Bool = false, - isExternalTemporaryConnection: Bool = false + externalSFTPMustBeDirectory: Bool = false ) { self.terminalSettings = terminalSettings self.commandHistory = commandHistory @@ -248,11 +247,10 @@ final class MobileWorkspace: Identifiable { self.transientHost = transientHost self.connectionRoute = connectionRoute self.usernameHint = usernameHint - self.uriHostKeyFingerprint = uriHostKeyFingerprint + self.connectionOrigin = connectionOrigin self.advisoryTelnetPassword = advisoryTelnetPassword self.externalSFTPResourcePath = externalSFTPResourcePath self.externalSFTPMustBeDirectory = externalSFTPMustBeDirectory - self.isExternalTemporaryConnection = isExternalTemporaryConnection activitySession = WorkspaceActivityAttributes.Session( id: descriptor.id, title: descriptor.title, kind: descriptor.kind.rawValue, connected: false, @@ -312,39 +310,19 @@ final class MobileWorkspace: Identifiable { guard authenticationRequired, let host = connectionHost(authenticationHostID ?? descriptor.hostID) else { return nil } - if let proxy = host.proxy, authenticationIsProxy { + if host.proxy != nil, authenticationIsProxy { let staged = proxySubmissions[host.id] - let configuredIdentity = (staged?.identityID ?? proxy.identityID).flatMap { hosts.identity($0) } - let selectedIdentity: Identity? - if let configuredIdentity { - switch hosts.passwordAvailability(for: configuredIdentity) { - case .available, .notConfigured: selectedIdentity = configuredIdentity - case .missing, .failed: selectedIdentity = nil - } - } else { - selectedIdentity = nil - } - return SSHAuthenticationPrompt( - host: Host( - id: host.id, name: String(localized: "Proxy for \(host.name)"), - host: proxy.hostname, port: proxy.port), - selectedIdentityID: selectedIdentity?.id, - username: staged?.username ?? proxy.username, + return SSHAuthenticationPromptResolver.proxy( + for: host, using: hosts, submission: staged, failureMessage: connectionFailure, mode: authenticationMode, - target: .proxy(ownerHostID: host.id), - allowsSaving: !isExternalTemporaryConnection) + allowsSaving: connectionOrigin.allowsPersistence) } let staged = submissions[host.id] - let identity = staged?.identityID.flatMap { hosts.identity($0) } ?? hosts.identity(for: host) - let keyID = staged?.keyID ?? identity?.keyID - return SSHAuthenticationPrompt( - host: host, selectedIdentityID: identity?.id, username: username, - selectedKeyID: keyID, failureMessage: connectionFailure, - requiresPassphrase: keyID.flatMap { hosts.key($0) }?.requiresPassphraseEntry == true - && staged?.requiresPasswordReentry != true - && staged?.passphrase == nil, + return SSHAuthenticationPromptResolver.host( + host, using: hosts, submission: staged, username: username, + failureMessage: connectionFailure, mode: authenticationMode, - allowsSaving: !isExternalTemporaryConnection) + allowsSaving: connectionOrigin.allowsPersistence) } private var authenticationIsProxy = false @@ -723,18 +701,15 @@ final class MobileWorkspace: Identifiable { guard generation == token, !Task.isCancelled else { return } let request = SSHConnectionRequest( configuration: config, - authorizeHostKey: { [weak self, weak hosts] challenge in - guard let self, let hosts else { throw CancellationError() } - return await self.connection.authorizeHostKey( - challenge, using: hosts, - uriFingerprint: self.uriHostKeyFingerprint, - destinationHostID: self.descriptor.hostID - ) + authorizeHostKey: { [weak self] challenge in + guard let self else { throw CancellationError() } + return await self.connection.authorizeHostKey(challenge) }, keyboardInteractive: { [weak self] challenge in guard let self, await self.generation == token else { throw CancellationError() } return try await self.connection.answerKeyboardInteractive(challenge) - } + }, + hostKeyConstraints: connectionOrigin.hostKeyConstraints ) var externalFile: RemoteFile? @@ -1194,7 +1169,7 @@ final class MobileWorkspace: Identifiable { func trustHostKey(remember: Bool = true) { guard let observation = connection.pendingHostKey?.observation else { return } - if remember, !isExternalTemporaryConnection { + if remember, connectionOrigin.allowsPersistence { connection.trustHostKey(observation, using: hosts) } else { guard connection.useTemporaryHostKey(observation) else { return } @@ -1673,7 +1648,7 @@ final class MobileWorkspace: Identifiable { do { let url = try await sftpEdits.open( file, hostID: descriptor.hostID, connection: .context(transferContext), client: sftp) - if !isExternalTemporaryConnection { + if connectionOrigin.allowsPersistence { sftpHistory.record(kind: .file, path: file.path, for: descriptor.hostID) } completion(url) @@ -1695,7 +1670,7 @@ final class MobileWorkspace: Identifiable { connection: .context(transferContext), scopedRoot: directory, overwrite: false ) { [sftpHistory, hostID = descriptor.hostID, path = file.path] state in guard state == .completed else { return } - if !self.isExternalTemporaryConnection { + if self.connectionOrigin.allowsPersistence { sftpHistory.record(kind: .file, path: path, for: hostID) } completion(localURL) diff --git a/TFM/App/AppModel.swift b/TFM/App/AppModel.swift index 1bec93ef..d05cabd4 100644 --- a/TFM/App/AppModel.swift +++ b/TFM/App/AppModel.swift @@ -793,8 +793,8 @@ final class AppModel { if request.kind == .ssh { let workspace = window.openSSHSession( host: host, usernameHint: request.username, connectionRoute: route) - workspace.focusedPane.sshSession?.uriHostKeyFingerprint = request.hostKeyFingerprint - workspace.focusedPane.sshSession?.isExternalTemporaryConnection = true + workspace.focusedPane.sshSession?.connectionOrigin = .externalLink( + hostKeyConstraints: request.hostKeyFingerprint.map { [host.id: $0] } ?? [:]) return } let workspace = window.openSFTPChannel(host: host) diff --git a/TFM/Features/SFTP/SFTPChannelModel.swift b/TFM/Features/SFTP/SFTPChannelModel.swift index 092d0e15..0c0cc7d3 100644 --- a/TFM/Features/SFTP/SFTPChannelModel.swift +++ b/TFM/Features/SFTP/SFTPChannelModel.swift @@ -176,8 +176,7 @@ final class SFTPChannelModel: Identifiable { private var transientHost: Host? private var usernameHint: String? private var connectionRoute: SSHConnectionRoute? - private var uriHostKeyFingerprint: SSHURIHostKeyFingerprint? - private var isExternalTemporaryConnection = false + private var connectionOrigin: RemoteConnectionOrigin = .library private var externalResourcePath: String? private var externalResourceMustBeDirectory = false var openExternalFile: ((RemoteFile) -> Void)? @@ -220,14 +219,14 @@ final class SFTPChannelModel: Identifiable { func configureExternalConnection( host: Host, username: String?, route: SSHConnectionRoute, - fingerprint: SSHURIHostKeyFingerprint?, resourcePath: String?, + fingerprint: SSHHostKeyConstraint?, resourcePath: String?, mustBeDirectory: Bool, openFile: @escaping (RemoteFile) -> Void ) { transientHost = host usernameHint = username connectionRoute = route - uriHostKeyFingerprint = fingerprint - isExternalTemporaryConnection = true + connectionOrigin = .externalLink( + hostKeyConstraints: fingerprint.map { [host.id: $0] } ?? [:]) externalResourcePath = resourcePath externalResourceMustBeDirectory = mustBeDirectory openExternalFile = openFile @@ -423,11 +422,12 @@ final class SFTPChannelModel: Identifiable { proxySubmissions: attemptProxyAuthentications, temporaryHostKeys: temporaryHostKeys, trustPolicy: .allowChallenge) - } ?? store.sshJumpConfigurations( - for: host, submissions: attemptAuthentications, - proxySubmissions: attemptProxyAuthentications, - temporaryHostKeys: temporaryHostKeys, - trustPolicy: .allowChallenge), + } + ?? store.sshJumpConfigurations( + for: host, submissions: attemptAuthentications, + proxySubmissions: attemptProxyAuthentications, + temporaryHostKeys: temporaryHostKeys, + trustPolicy: .allowChallenge), charset: host.charset, authenticationReference: SSHAuthenticationReference( identityID: suppliedAuthentication.identityID, @@ -452,20 +452,17 @@ final class SFTPChannelModel: Identifiable { let client = try await coordinator.connectSFTP( using: SSHConnectionRequest( configuration: configuration, - authorizeHostKey: { [weak self, weak store] challenge in - guard let self, let store else { throw CancellationError() } - return await self.connection.authorizeHostKey( - challenge, using: store, - uriFingerprint: self.uriHostKeyFingerprint, - destinationHostID: self.hostID - ) + authorizeHostKey: { [weak self] challenge in + guard let self else { throw CancellationError() } + return await self.connection.authorizeHostKey(challenge) }, keyboardInteractive: { [weak self] challenge in guard let self, await self.connectionAttemptID == attemptID else { throw CancellationError() } return try await self.connection.answerKeyboardInteractive(challenge) - } + }, + hostKeyConstraints: connectionOrigin.hostKeyConstraints ), progress: progressHandler(attemptID: attemptID), connectionFailure: { [weak self] message in @@ -1039,7 +1036,7 @@ final class SFTPChannelModel: Identifiable { } func recordOpenedFile(at path: String) { - if !isExternalTemporaryConnection { + if connectionOrigin.allowsPersistence { historyStore?.record(kind: .file, path: path, for: hostID) } } @@ -1169,21 +1166,19 @@ final class SFTPChannelModel: Identifiable { ) { connection.pauseForUserInteraction() if !preserveSubmission { suppliedAuthentications[host.id] = nil } - let identity = store.identity(for: host) let submission = suppliedAuthentications[host.id] - let selectedKeyID = submission?.keyID ?? identity?.keyID - let username = submission?.username ?? identity?.username ?? "" + let selectedKeyID = submission?.keyID ?? store.identity(for: host)?.keyID + let username = submission?.username ?? store.identity(for: host)?.username ?? "" let needsPassphrase = requiresPassphrase ?? (!username.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty && selectedKeyID.flatMap { store.key($0) }?.requiresPassphraseEntry == true) - pendingAuthentication = SSHAuthenticationPrompt( - host: host, selectedIdentityID: submission?.identityID ?? identity?.id, - username: username, + pendingAuthentication = SSHAuthenticationPromptResolver.host( + host, using: store, submission: submission, username: username, selectedKeyID: selectedKeyID, failureMessage: failure, - requiresPassphrase: needsPassphrase, - mode: mode, allowsSaving: !isExternalTemporaryConnection + mode: mode, requiresPassphrase: needsPassphrase, + allowsSaving: connectionOrigin.allowsPersistence ) status = .disconnected switch mode { @@ -1202,27 +1197,13 @@ final class SFTPChannelModel: Identifiable { host: Host, store: any RemoteSessionHostStore, failure: String? = nil, mode: SSHAuthenticationPrompt.Mode = .localIdentity ) { - guard let proxy = host.proxy else { return } + guard host.proxy != nil else { return } connection.pauseForUserInteraction() let staged = suppliedProxyAuthentications.removeValue(forKey: host.id) - let configuredIdentity = (staged?.identityID ?? proxy.identityID).flatMap(store.identity) - let selectedIdentity: Identity? - if let configuredIdentity { - switch store.passwordAvailability(for: configuredIdentity) { - case .available, .notConfigured: selectedIdentity = configuredIdentity - case .missing, .failed: selectedIdentity = nil - } - } else { - selectedIdentity = nil - } - pendingAuthentication = SSHAuthenticationPrompt( - host: Host( - id: host.id, name: String(localized: "Proxy for \(host.name)"), - host: proxy.hostname, port: proxy.port), - selectedIdentityID: selectedIdentity?.id, - username: staged?.username ?? proxy.username, failureMessage: failure, - mode: mode, target: .proxy(ownerHostID: host.id), - allowsSaving: !isExternalTemporaryConnection) + pendingAuthentication = SSHAuthenticationPromptResolver.proxy( + for: host, using: store, submission: staged, + failureMessage: failure, mode: mode, + allowsSaving: connectionOrigin.allowsPersistence) status = .disconnected connectionJourney.waitForProxyAuthentication(at: host.id, failure: failure) } diff --git a/TFM/Features/Terminal/SSHSessionModel.swift b/TFM/Features/Terminal/SSHSessionModel.swift index 8b10da9a..859bb703 100644 --- a/TFM/Features/Terminal/SSHSessionModel.swift +++ b/TFM/Features/Terminal/SSHSessionModel.swift @@ -150,8 +150,8 @@ final class SSHSessionModel: Identifiable { private(set) var transferSnapshot: SSHAdditionalConnectionSnapshot? private var latestViewport = InMemoryTerminalViewport(columns: 80, rows: 24) var connectionRoute: SSHConnectionRoute? - var uriHostKeyFingerprint: SSHURIHostKeyFingerprint? - var isExternalTemporaryConnection = false + var connectionOrigin: RemoteConnectionOrigin = .library + var isExternalTemporaryConnection: Bool { !connectionOrigin.allowsPersistence } private var transientHost: Host? private var usernameHint: String? private var suppliedAuthentications: [Host.ID: SSHAuthenticationSubmission] = [:] @@ -607,13 +607,9 @@ final class SSHSessionModel: Identifiable { host: host, username: username, credentials: credentials, expectedHostKeys: expectedHostKeys, proxy: proxy, jumps: jumps, authenticationReference: authenticationReference, attemptID: attemptID, - authorizeHostKey: { [weak self, weak store] challenge in - guard let self, let store else { throw CancellationError() } - return await self.connection.authorizeHostKey( - challenge, using: store, - uriFingerprint: self.uriHostKeyFingerprint, - destinationHostID: self.hostID - ) + authorizeHostKey: { [weak self] challenge in + guard let self else { throw CancellationError() } + return await self.connection.authorizeHostKey(challenge) } ) } catch is CancellationError { @@ -854,7 +850,8 @@ final class SSHSessionModel: Identifiable { keyboardInteractive: { [weak self] challenge in guard let self, await self.connectionAttemptID == attemptID else { throw CancellationError() } return try await self.connection.answerKeyboardInteractive(challenge) - } + }, + hostKeyConstraints: connectionOrigin.hostKeyConstraints ), shellIntegrationEnabled: isShellIntegrationEnabled, columns: latestViewport.columns, rows: latestViewport.rows, @@ -976,17 +973,16 @@ final class SSHSessionModel: Identifiable { mode: SSHAuthenticationPrompt.Mode = .localIdentity, preserveSubmission: Bool = false ) { + guard let store = statusStore else { return } connection.pauseForUserInteraction() if !preserveSubmission { suppliedAuthentications[host.id] = nil } - pendingAuthentication = SSHAuthenticationPrompt( - host: host, - selectedIdentityID: preserveSubmission - ? suppliedAuthentications[host.id]?.identityID : host.identityID, - username: username, - selectedKeyID: selectedKeyID, + pendingAuthentication = SSHAuthenticationPromptResolver.host( + host, using: store, + submission: preserveSubmission ? suppliedAuthentications[host.id] : nil, + username: username, selectedKeyID: selectedKeyID, failureMessage: failureMessage?.isEmpty == false ? failureMessage : nil, - requiresPassphrase: requiresPassphrase, mode: mode, - allowsSaving: !isExternalTemporaryConnection + mode: mode, requiresPassphrase: requiresPassphrase, + allowsSaving: connectionOrigin.allowsPersistence ) status = .disconnected switch mode { @@ -1010,29 +1006,14 @@ final class SSHSessionModel: Identifiable { failureMessage: String? = nil, mode: SSHAuthenticationPrompt.Mode = .localIdentity ) { - guard let proxy = host.proxy else { return } + guard host.proxy != nil else { return } connection.pauseForUserInteraction() let staged = suppliedProxyAuthentications[host.id] - let configuredIdentity = (staged?.identityID ?? proxy.identityID).flatMap(store.identity) - let selectedIdentity: Identity? - if let configuredIdentity { - switch store.passwordAvailability(for: configuredIdentity) { - case .available, .notConfigured: selectedIdentity = configuredIdentity - case .missing, .failed: selectedIdentity = nil - } - } else { - selectedIdentity = nil - } suppliedProxyAuthentications[host.id] = nil - pendingAuthentication = SSHAuthenticationPrompt( - host: Host( - id: host.id, name: String(localized: "Proxy for \(host.name)"), - host: proxy.hostname, port: proxy.port), - selectedIdentityID: selectedIdentity?.id, - username: staged?.username ?? proxy.username, + pendingAuthentication = SSHAuthenticationPromptResolver.proxy( + for: host, using: store, submission: staged, failureMessage: failureMessage, mode: mode, - target: .proxy(ownerHostID: host.id), - allowsSaving: !isExternalTemporaryConnection) + allowsSaving: connectionOrigin.allowsPersistence) status = .disconnected connectionJourney.waitForProxyAuthentication(at: host.id, failure: failureMessage) } @@ -1078,8 +1059,7 @@ final class SSHSessionModel: Identifiable { let host = transientHost ?? Host(id: hostID, name: title, host: title) let copy = SSHSessionModel(host: host, usernameHint: usernameHint) copy.connectionRoute = connectionRoute - copy.uriHostKeyFingerprint = uriHostKeyFingerprint - copy.isExternalTemporaryConnection = isExternalTemporaryConnection + copy.connectionOrigin = connectionOrigin copy.connectionCoordinator = connectionCoordinator copy.commandHistoryStore = commandHistoryStore return copy diff --git a/TFMTests/ExternalConnectionURLTests.swift b/TFMTests/ExternalConnectionURLTests.swift index 62b8df64..3f66cf67 100644 --- a/TFMTests/ExternalConnectionURLTests.swift +++ b/TFMTests/ExternalConnectionURLTests.swift @@ -12,13 +12,18 @@ struct ExternalConnectionURLTests { defer { app.shutdownForTermination() } app.openExternalConnection( - try #require(URL(string: "ssh://deploy@example.invalid:2222")), + try #require( + URL( + string: + "ssh://deploy;fingerprint=ssh-rsa-00-11-22-33-44-55-66-77-88-99-aa-bb-cc-dd-ee-ff@example.invalid:2222" + )), in: window) let session = try #require(window.selectedWorkspace?.focusedPane.sshSession) #expect(session.isExternalTemporaryConnection) #expect(session.connectionRoute?.hosts.first?.host == "example.invalid") #expect(session.connectionRoute?.hosts.first?.port == 2222) + #expect(session.connectionOrigin.hostKeyConstraints[session.hostID]?.legacyMD5.count == 16) #expect(app.hostLibrary.hosts.isEmpty) } diff --git a/TFMTests/MetricsSnapshotTests.swift b/TFMTests/MetricsSnapshotTests.swift index 3076af72..73e26ac8 100644 --- a/TFMTests/MetricsSnapshotTests.swift +++ b/TFMTests/MetricsSnapshotTests.swift @@ -51,11 +51,12 @@ struct MetricsSnapshotTests { } @Test - func lifecycleStateIsStructuredAndIndependentOfLocalizedText() throws { + func lifecycleStateIsStructuredAndIndependentOfLocalizedText() async throws { let host = Host(name: "Monitor", host: "monitor.example") let model = ServerMetricsModel( hosts: HostLibraryModel(hosts: [host]), preferences: SnapshotPreferences(hostID: host.id), - makeSampler: { preconditionFailure("Snapshot reads cannot create samplers") } + makeSampler: { preconditionFailure("Snapshot reads cannot create samplers") }, + clock: MetricsClock(sleep: { _ in try await Task.sleep(for: .seconds(3_600)) }) ) model.start() defer { model.shutdown() } @@ -66,10 +67,16 @@ struct MetricsSnapshotTests { row.collectionState = state #expect(model.snapshot(for: host.id)?.state == state) } - row.hostKey = SSHHostKeyObservation( + let observation = SSHHostKeyObservation( hostname: host.host, port: 22, algorithm: .ed25519, fingerprint: "SHA256:test") + let authorization = Task { + await row.hostKeyAuthorization.authorize( + SSHHostKeyChallenge(hostID: host.id, observation: observation, expectedHostKeys: [])) + } + for _ in 0..<100 where row.hostKey == nil { await Task.yield() } #expect(model.snapshot(for: host.id)?.state == .awaitingAuthentication) - row.hostKey = nil + row.hostKeyAuthorization.reject() + #expect(await authorization.value == .reject) model.suspend() #expect(model.snapshot(for: host.id)?.state == .sleeping) row.selection.enabled = false