refactor: match SSH authorized host key
This commit is contained in:
1 parent
05bdc7881e
commit
fce93864c6
22 files changed
+560
-436
No files matched your search
@@ -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? {
|
||||
|
||||
+112
-94
@@ -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()
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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.
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user