refactor: match SSH authorized host key

This commit is contained in:
Kaede Akino committed 2026-09-22 07:20:10 +08:00
1 parent 05bdc7881e
commit fce93864c6
22 files changed
+560 -436

No files matched your search

+12 -4
View File
@@ -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))
@@ -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) }
}
@@ -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? {
@@ -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<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.
@@ -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<SSHHostKeyDecision, Never>?
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()
}
}
@@ -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)
}
}
@@ -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<Void, Never>?
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<SSHHostKeyDecision>.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<SSHHostKeyDecision>
}
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<SSHHostKeyDecision> { 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
}
}
@@ -32,14 +32,49 @@ public nonisolated struct SSHHostKeyChallenge: Hashable, Sendable {
public var hostID: Host.ID
public var observation: SSHHostKeyObservation
public var expectedHostKeys: Set<SSHHostKeyIdentity>
/// 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<SSHHostKeyIdentity>
expectedHostKeys: Set<SSHHostKeyIdentity>,
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 {
@@ -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)
@@ -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
)
)
@@ -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 {
@@ -89,10 +89,12 @@ public nonisolated enum SSHConnectionOpener {
public static func authorizeHostKey(
hostID: TermindDomain.Host.ID, observed: SSHHostKeyObservation,
expected: Set<SSHHostKeyIdentity>, authorizer: SSHHostKeyAuthorizer?
expected: Set<SSHHostKeyIdentity>, 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(
@@ -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<SSHHostKeyIdentity>, authorizer: SSHHostKeyAuthorizer?
expected: Set<SSHHostKeyIdentity>, 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(),
@@ -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()
+5
View File
@@ -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,
+14
View File
@@ -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.
+3 -3
View File
@@ -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
}
+21 -46
View File
@@ -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)
+2 -2
View File
@@ -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)
+27 -46
View File
@@ -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)
}
+19 -39
View File
@@ -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
+6 -1
View File
@@ -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)
}
+11 -4
View File
@@ -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