diff --git a/FrameworkTests/SSHTailscaleAuthenticationTests.swift b/FrameworkTests/SSHTailscaleAuthenticationTests.swift new file mode 100644 index 00000000..c3bf4281 --- /dev/null +++ b/FrameworkTests/SSHTailscaleAuthenticationTests.swift @@ -0,0 +1,207 @@ +import Foundation +import TermindApplication +import TermindDomain +import Testing + +@testable import TermindRemoteInfrastructure + +@Suite @MainActor +struct SSHTailscaleAuthenticationTests { + @Test(arguments: [ + ("SSH-2.0-Tailscale", true), + ("SSH-2.0-Tailscale_1.90 optional comment", true), + ("SSH-2.0-OpenSSH_9.0 Tailscale", false), + ("SSH-2.0-TailscaleProxy", false), + ("host.ts.net", false), + ("100.64.0.1", false), + ]) + func detectionUsesOnlyTheServerSoftware(banner: String, expected: Bool) { + #expect(SSHTailscaleAuthentication.isTailscaleServer(banner) == expected) + #expect(!SSHTailscaleAuthentication.isTailscaleServer(nil)) + } + + @Test func serverSuccessDismissesApprovalWithoutAResponse() async throws { + let controller = RemoteSessionConnectionController() + let configuration = configuration() + let host = Host(id: configuration.hostID, name: "Server", host: configuration.host) + controller.journey.begin(host: host, route: [host], purpose: .sshSession, username: configuration.username) + let exchange = SSHKeyboardInteractiveExchange( + configuration: configuration, handler: { try await controller.answerKeyboardInteractive($0) }) + let probe = ApprovalProbe() + let opening = start(exchange, probe) + defer { exchange.cancel() } + let challenge = try await wait(controller.keyboardAuthentication) + #expect(challenge.username == "deploy") + #expect(challenge.isTailscaleApproval) + #expect(challenge.prompts.isEmpty) + #expect(controller.journey.phase == .waiting) + #expect(!controller.journey.plainTextLog.contains(challenge.instruction)) + probe.finish(.authenticated) + #expect(try await opening.value == .authenticated) + await exchange.waitForTailscaleApprovalDismissal() + #expect(controller.keyboardAuthentication.pending.isEmpty) + #expect(controller.journey.phase == .connecting) + try exchange.checkTailscaleApproval() + } + + @Test func duplicateBannersAndRetiredRequestsDoNotCancelTheNextApproval() async throws { + let queue = SSHKeyboardInteractiveQueue() + let exchange = SSHKeyboardInteractiveExchange(configuration: configuration(), handler: queue.handler) + let probe = ApprovalProbe() + let opening = start(exchange, probe) + defer { exchange.cancel() } + let first = try await wait(queue) + let count = probe.readCount + for _ in 0..<100 where probe.readCount < count + 3 { try await Task.sleep(for: .milliseconds(5)) } + #expect(queue.pending.map(\.id) == [first.id]) + probe.finish(.pending("Approve at https://login.example/second")) + let second = try await wait(queue, excluding: first.id) + #expect(second.connectionID == first.connectionID) + #expect(second.round == 2) + queue.cancel(first.id) + probe.finish(.authenticated) + #expect(try await opening.value == .authenticated) + await exchange.waitForTailscaleApprovalDismissal() + #expect(queue.pending.isEmpty) + } + + @Test(arguments: [false, true]) + func cancellationClosesOnlyItsOwnPrompt(cancelConnection: Bool) async throws { + let queue = SSHKeyboardInteractiveQueue() + let exchange = SSHKeyboardInteractiveExchange(configuration: configuration(), handler: queue.handler) + let other = SSHKeyboardInteractiveExchange(configuration: configuration(), handler: queue.handler) + let probe = ApprovalProbe() + let opening = start(exchange, probe) + let challenge = try await wait(queue) + try other.presentTailscaleApproval("Other connection") + defer { other.endTailscaleApproval() } + if cancelConnection { exchange.cancel() } else { queue.cancel(challenge.id) } + await #expect(throws: CancellationError.self) { try await opening.value } + await exchange.waitForTailscaleApprovalDismissal() + let retained = try await wait(queue, excluding: challenge.id) + #expect(retained.connectionID != challenge.connectionID) + other.endTailscaleApproval() + await other.waitForTailscaleApprovalDismissal() + #expect(queue.pending.isEmpty) + } + + @Test func deadlineIncludesTimeWithTheApprovalFormOpen() async throws { + let queue = SSHKeyboardInteractiveQueue() + let exchange = SSHKeyboardInteractiveExchange(configuration: configuration(), handler: queue.handler) + let probe = ApprovalProbe() + let opening = start(exchange, probe) + _ = try await wait(queue) + probe.advanceTime() + await #expect { + try await opening.value + } throws: { error in + if case TailscaleConfigurationError.approvalTimedOut = error { return true } + return false + } + await exchange.waitForTailscaleApprovalDismissal() + #expect(queue.pending.isEmpty) + } + + @Test func disconnectDismissesApprovalAndDoesNotExposeTheBannerInErrors() async throws { + let queue = SSHKeyboardInteractiveQueue() + let exchange = SSHKeyboardInteractiveExchange(configuration: configuration(), handler: queue.handler) + let probe = ApprovalProbe() + let opening = start(exchange, probe) + let challenge = try await wait(queue) + probe.disconnect() + await #expect { + try await opening.value + } throws: { error in + if case TermindSSHError.disconnected = error { return true } + return false + } + await exchange.waitForTailscaleApprovalDismissal() + #expect(queue.pending.isEmpty) + let failure = SSHTailscaleAuthentication.failure(code: -18).localizedDescription + #expect(!failure.contains(challenge.instruction)) + #expect(!failure.contains("https://")) + #expect(failure.contains("libssh2 -18")) + } + + @Test func forbiddenInteractionAndMissingResponderFailWithoutShowingAForm() throws { + let exchange = SSHKeyboardInteractiveExchange( + configuration: configuration(), + handler: { _ in + Issue.record("Forbidden approval") + return [] + }, + allowsUserInteraction: false) + for candidate in [exchange, nil] { + #expect(throws: SSHInteractionRequired.self) { + try SSHTailscaleAuthentication.run(exchange: candidate) { .pending("Browser approval") } + } + } + } + + @Test func methodsAndImmediateSuccessDoNotNeedAnInteractiveResponder() throws { + for result in [SSHTailscaleAuthentication.Step.authenticated, .methods(["password", "publickey"])] { + #expect(try SSHTailscaleAuthentication.run(exchange: nil) { result } == result) + } + } + + @Test func responderAcknowledgementDoesNotAuthorizeTheConnection() async throws { + let exchange = SSHKeyboardInteractiveExchange(configuration: configuration(), handler: { _ in [] }) + let probe = ApprovalProbe() + let opening = start(exchange, probe) + for _ in 0..<100 where probe.readCount < 3 { try await Task.sleep(for: .milliseconds(5)) } + #expect(probe.readCount >= 3) + probe.advanceTime() + await #expect { + try await opening.value + } throws: { error in + if case TailscaleConfigurationError.approvalTimedOut = error { return true } + return false + } + await exchange.waitForTailscaleApprovalDismissal() + } + + private func configuration() -> SSHConnectionConfiguration { + .init( + hostID: UUID(), host: "server.example", port: 22, username: "deploy", credentials: [], expectedHostKeys: []) + } + + private func start( + _ exchange: SSHKeyboardInteractiveExchange, _ probe: ApprovalProbe + ) -> Task { + Task.detached { + try SSHTailscaleAuthentication.run( + exchange: exchange, now: { probe.now }, pause: { Thread.sleep(forTimeInterval: 0.005) }, + step: { try probe.next() }) + } + } + + private func wait( + _ queue: SSHKeyboardInteractiveQueue, excluding: UUID? = nil + ) async throws -> SSHKeyboardInteractiveChallenge { + for _ in 0..<200 { + if let challenge = queue.current, challenge.id != excluding { return challenge } + try await Task.sleep(for: .milliseconds(5)) + } + throw NSError(domain: "Missing approval prompt", code: 1) + } +} + +private nonisolated final class ApprovalProbe: @unchecked Sendable { + private let lock = NSLock() + private var instant = ContinuousClock.now + private var reads = 0 + private var stopped = false + private var step = SSHTailscaleAuthentication.Step.pending("Approve at https://login.example/ssh/check") + var now: ContinuousClock.Instant { lock.withLock { instant } } + var readCount: Int { lock.withLock { reads } } + func finish(_ step: SSHTailscaleAuthentication.Step) { lock.withLock { self.step = step } } + func advanceTime() { lock.withLock { instant = instant.advanced(by: .seconds(601)) } } + func disconnect() { lock.withLock { stopped = true } } + func next() throws -> SSHTailscaleAuthentication.Step { + try lock.withLock { + reads += 1 + if stopped { throw TermindSSHError.disconnected } + return step + } + } +} diff --git a/FrameworkTests/TailscaleSSHWireTests.swift b/FrameworkTests/TailscaleSSHWireTests.swift index 5d2a28b7..029c83a2 100644 --- a/FrameworkTests/TailscaleSSHWireTests.swift +++ b/FrameworkTests/TailscaleSSHWireTests.swift @@ -1,3 +1,5 @@ +import CSSH2 +import Darwin import Foundation import TermindApplication import TermindDomain @@ -40,10 +42,261 @@ nonisolated struct TailscaleSSHWireTests { } } + @Test @MainActor func freeOrdinaryHostUsesItsSavedUsernameWithoutAnEmbeddedTailnet() async throws { + let fixture = try Fixture.load() + let identity = Identity(name: "Username only", username: "none") + let host = Host(name: "Tailscale SSH", host: "127.0.0.1", port: fixture.tailscalePort, identityID: identity.id) + let library = HostLibraryModel( + hosts: [host], identities: [identity], + knownHosts: [ + KnownHostEntry( + hostname: host.host, port: host.port, algorithm: .rsa, fingerprint: fixture.hostFingerprint) + ]) + library.hostAccess = HostAccessPolicy(entitlement: .free) + let configuration = try await library.prepareSSHConnection( + for: SSHConnectionRoute(hosts: [host]), submissions: [:] + ).resolve() + #expect(configuration.username == identity.username) + #expect(configuration.credentials.isEmpty) + #expect(configuration.network == nil && configuration.tailscaleNodeID == nil) + let connection = SSHConnection() + try await connection.connect( + using: SSHConnectionRequest(configuration: configuration, interactionPolicy: .forbidden)) + do { + let result = try await connection.execute( + "fixture", workingDirectory: nil, stdin: nil, timeout: 5, outputLimit: 1024, onOutput: nil) + #expect(result.stdout == "fixture\n") + } catch { + await connection.disconnect() + throw error + } + await connection.disconnect() + } + + @Test(arguments: [false, true]) @MainActor + func approvalAutomaticallyContinuesOnTheSameTransport(managed: Bool) async throws { + let fixture = try Fixture.load() + let queue = SSHKeyboardInteractiveQueue() + let username = "check-\(UUID().uuidString)" + var configuration = fixture.configuration(username: username) + if managed { + configuration.host = "fixture.test.ts.net" + configuration.port = 22 + configuration.network = LoopbackTailnet(port: fixture.tailscalePort, fingerprint: fixture.hostFingerprint) + configuration.tailscaleNodeID = "fixture-node" + } + let connection = SSHConnection() + let request = SSHConnectionRequest(configuration: configuration, keyboardInteractive: queue.handler) + let opening = Task { try await connection.connect(using: request) } + defer { opening.cancel() } + do { + let challenge = try await waitForApproval(queue) + #expect(challenge.username == username && challenge.isTailscaleApproval) + try await fixture.control("repeat", username: username) + try await fixture.control("accept", username: username) + try await opening.value + #expect(queue.pending.isEmpty) + let files = try await connection.openSFTPChannel() + #expect(try await files.readFile(at: "/fixture.txt", maximumBytes: 1024) == Data("fixture\n".utf8)) + await files.disconnect() + } catch { + opening.cancel() + await connection.disconnect() + throw error + } + await connection.disconnect() + } + + @Test @MainActor func terminalAndJumpUseTheSameAutomaticApproval() async throws { + let fixture = try Fixture.load() + let queue = SSHKeyboardInteractiveQueue() + let jumpUser = "check-\(UUID().uuidString)" + let targetUser = "check-\(UUID().uuidString)" + let jumpID = UUID() + var configuration = fixture.configuration(username: targetUser) + configuration.jumps = [ + .init( + id: jumpID, name: "Jump", host: "127.0.0.1", port: fixture.tailscalePort, + username: jumpUser, credentials: [], expectedHostKeys: configuration.expectedHostKeys) + ] + let channel = SSHPTYChannel(output: { _ in }, exit: { _ in }) + let request = SSHConnectionRequest(configuration: configuration, keyboardInteractive: queue.handler) + let opening = Task { + try await channel.connect(using: request, shellIntegrationEnabled: false, columns: 80, rows: 24) + } + defer { opening.cancel() } + do { + let jump = try await waitForApproval(queue) + #expect(jump.hostID == jumpID) + try await fixture.control("accept", username: jumpUser) + let target = try await waitForApproval(queue, excluding: jump.id) + #expect(target.hostID == configuration.hostID) + #expect(target.connectionID != jump.connectionID) + try await fixture.control("accept", username: targetUser) + try await opening.value + #expect(queue.pending.isEmpty) + } catch { + opening.cancel() + await channel.disconnect() + throw error + } + await channel.disconnect() + } + + @Test(arguments: ["reject", "disconnect", "cancel"]) @MainActor + func failedOrCancelledApprovalRemovesThePrompt(action: String) async throws { + let fixture = try Fixture.load() + let queue = SSHKeyboardInteractiveQueue() + let username = "check-\(UUID().uuidString)" + let request = SSHConnectionRequest( + configuration: fixture.configuration(username: username), keyboardInteractive: queue.handler) + let connection = SSHConnection() + let opening = Task { try await connection.connect(using: request) } + defer { opening.cancel() } + do { + let challenge = try await waitForApproval(queue) + if action == "cancel" { + queue.cancel(challenge.id) + await #expect(throws: CancellationError.self) { try await opening.value } + } else { + try await fixture.control(action, username: username) + await #expect(throws: TermindSSHError.self) { try await opening.value } + } + #expect(queue.pending.isEmpty) + } catch { + opening.cancel() + await connection.disconnect() + throw error + } + await connection.disconnect() + } + + @Test func forbiddenApprovalNeverInvokesTheResponder() async throws { + let fixture = try Fixture.load() + let connection = SSHConnection() + await #expect(throws: SSHInteractionRequired.self) { + try await connection.connect( + using: SSHConnectionRequest( + configuration: fixture.configuration(username: "check-\(UUID().uuidString)"), + keyboardInteractive: { _ in + Issue.record("Unexpected approval form") + return [] + }, + interactionPolicy: .forbidden)) + } + await connection.disconnect() + } + + @Test(arguments: [false, true]) + func passwordFallbackAndOrdinaryBannersRetainNormalAuthentication(tailscale: Bool) async throws { + let fixture = try Fixture.load() + var configuration = fixture.configuration(username: tailscale ? "password" : "banner-password") + if !tailscale { configuration.port = fixture.port } + configuration.credentials = [.password("fixture-password")] + let connection = SSHConnection() + try await connection.connect( + using: SSHConnectionRequest( + configuration: configuration, + keyboardInteractive: { _ in + Issue.record("An accepted banner needs no form") + return [] + })) + await connection.disconnect() + } + + @Test(arguments: [false, true]) func hostKeyMismatchNeverReachesApproval(managed: Bool) async throws { + let fixture = try Fixture.load() + var configuration = fixture.configuration(username: "check-\(UUID().uuidString)") + configuration.expectedHostKeys = [.init(algorithm: .rsa, fingerprint: "SHA256:wrong")] + if managed { + configuration.host = "fixture.test.ts.net" + configuration.network = LoopbackTailnet(port: fixture.tailscalePort, fingerprint: "SHA256:wrong") + configuration.tailscaleNodeID = "fixture-node" + } + let connection = SSHConnection() + await #expect(throws: (any Error).self) { + try await connection.connect( + using: SSHConnectionRequest( + configuration: configuration, + keyboardInteractive: { _ in + Issue.record("Host key must be verified first") + return [] + })) + } + await connection.disconnect() + } + + @Test func managedHostDoesNotFallBackToConfiguredPassword() async throws { + let fixture = try Fixture.load() + var configuration = fixture.configuration(username: "password") + configuration.host = "fixture.test.ts.net" + configuration.network = LoopbackTailnet(port: fixture.tailscalePort, fingerprint: fixture.hostFingerprint) + configuration.tailscaleNodeID = "fixture-node" + configuration.credentials = [.password("fixture-password")] + let connection = SSHConnection() + await #expect(throws: TermindSSHError.self) { + try await connection.connect(using: SSHConnectionRequest(configuration: configuration)) + } + await connection.disconnect() + } + + @Test(arguments: [false, true], ["none", "password"]) + func queryRestoresSessionSettings(blocking: Bool, username: String) async throws { + let fixture = try Fixture.load() + try await Task.detached { + #expect(libssh2_init(0) == 0) + let socket = try SSHSocketConnector.open(host: "127.0.0.1", port: fixture.tailscalePort) + defer { Darwin.close(socket) } + let session = try #require(libssh2_session_init_ex(nil, nil, nil, nil)) + defer { _ = libssh2_session_free(session) } + libssh2_session_set_blocking(session, 1) + try SSHConnectionOpener.performHandshake(session, socket: socket, peer: "fixture", timeout: 5) + libssh2_session_set_blocking(session, blocking ? 1 : 0) + libssh2_session_set_read_timeout(session, 3) + let result = try SSHTailscaleAuthentication.query(username: username, session: session, exchange: nil) + #expect(result == (username == "none" ? .authenticated : .methods(["password"]))) + #expect(libssh2_session_get_blocking(session) == (blocking ? 1 : 0)) + #expect(libssh2_session_get_read_timeout(session) == 3) + #expect(libssh2_session_get_timeout(session) == 5_000) + }.value + } + + @MainActor private func waitForApproval( + _ queue: SSHKeyboardInteractiveQueue, excluding: UUID? = nil + ) async throws -> SSHKeyboardInteractiveChallenge { + for _ in 0..<500 { + if let challenge = queue.current, challenge.id != excluding { return challenge } + try await Task.sleep(for: .milliseconds(10)) + } + throw NSError(domain: "Missing wire approval", code: 1) + } + private struct Fixture: Decodable { var port: Int + var tailscalePort: Int + var approvalPort: Int var hostFingerprint: String + func configuration(username: String) -> SSHConnectionConfiguration { + SSHConnectionConfiguration( + hostID: UUID(), host: "127.0.0.1", port: tailscalePort, username: username, + credentials: [], expectedHostKeys: [.init(algorithm: .rsa, fingerprint: hostFingerprint)]) + } + + func control(_ action: String, username: String) async throws { + try await Task.detached { + let socket = try SSHSocketConnector.open(host: "127.0.0.1", port: approvalPort) + defer { Darwin.close(socket) } + let command = Array("\(action) \(username)\n".utf8) + let sent = command.withUnsafeBytes { Darwin.send(socket, $0.baseAddress, $0.count, 0) } + #expect(sent == command.count) + var response = [UInt8](repeating: 0, count: 16) + let received = Darwin.recv(socket, &response, response.count, 0) + #expect(received > 0) + #expect(String(decoding: response.prefix(max(0, received)), as: UTF8.self) == "ok\n") + }.value + } + static func load() throws -> Self { let environment = ProcessInfo.processInfo.environment // Passing public endpoint metadata through the test runner also works in the app sandbox. diff --git a/Frameworks/TermindApplication/Connections/SSHKeyboardInteractiveChallenge.swift b/Frameworks/TermindApplication/Connections/SSHKeyboardInteractiveChallenge.swift index 993f7be7..509e2174 100644 --- a/Frameworks/TermindApplication/Connections/SSHKeyboardInteractiveChallenge.swift +++ b/Frameworks/TermindApplication/Connections/SSHKeyboardInteractiveChallenge.swift @@ -61,6 +61,9 @@ public nonisolated struct SSHKeyboardInteractiveChallenge: Identifiable, Hashabl } } +/// Tailscale approval stays presented while the transport waits for the server. +/// Handlers must dismiss their own prompt on task cancellation, including when +/// the transport retires the presentation after successful authentication. public typealias SSHKeyboardInteractiveHandler = @Sendable (SSHKeyboardInteractiveChallenge) async throws -> [String] diff --git a/Frameworks/TermindPresentation/SSHKeyboardInteractiveForm.swift b/Frameworks/TermindPresentation/SSHKeyboardInteractiveForm.swift index b6b55257..515a2f90 100644 --- a/Frameworks/TermindPresentation/SSHKeyboardInteractiveForm.swift +++ b/Frameworks/TermindPresentation/SSHKeyboardInteractiveForm.swift @@ -48,7 +48,7 @@ public struct SSHKeyboardInteractiveForm: View { HStack { Button(AppStrings.Common.cancel.swiftUIKey, action: cancel).keyboardShortcut(.cancelAction) Spacer() - if !challenge.isInformational { + if !challenge.isInformational && !challenge.isTailscaleApproval { Button(AppStrings.Common.continue.swiftUIKey, action: commit) .buttonStyle(.borderedProminent) .keyboardShortcut(.defaultAction) @@ -59,7 +59,8 @@ public struct SSHKeyboardInteractiveForm: View { .connectionFormChrome(isEnabled: usesContainerChrome) .background { if let keyboard { - keyboard.bridge(canSubmit: !submitted, submit: commit, cancel: cancel) + keyboard.bridge( + canSubmit: !submitted && !challenge.isTailscaleApproval, submit: commit, cancel: cancel) } } .task { @@ -79,6 +80,12 @@ public struct SSHKeyboardInteractiveForm: View { if let url = challenge.tailscaleApprovalURL { Link(AppStrings.Tailscale.openLogin.swiftUIKey, destination: url) } + if challenge.isTailscaleApproval { + HStack { + ProgressView().controlSize(.small) + Text(AppStrings.Tailscale.waitingForLogin).foregroundStyle(.secondary) + } + } ForEach(challenge.prompts.indices, id: \.self) { index in VStack(alignment: .leading, spacing: 5) { Text(verbatim: challenge.prompts[index].text) @@ -102,7 +109,7 @@ public struct SSHKeyboardInteractiveForm: View { } private func commit() { - guard !submitted else { return } + guard !submitted, !challenge.isTailscaleApproval else { return } submitted = true let response = answers answers = Array(repeating: "", count: answers.count) diff --git a/Frameworks/TermindRemoteInfrastructure/SSH/SSHKeyboardInteractiveExchange.swift b/Frameworks/TermindRemoteInfrastructure/SSH/SSHKeyboardInteractiveExchange.swift index 6065ba65..c0e566f4 100644 --- a/Frameworks/TermindRemoteInfrastructure/SSH/SSHKeyboardInteractiveExchange.swift +++ b/Frameworks/TermindRemoteInfrastructure/SSH/SSHKeyboardInteractiveExchange.swift @@ -16,6 +16,10 @@ public nonisolated final class SSHKeyboardInteractiveExchange: @unchecked Sendab private var task: Task? private var result: Result<[String], any Error>? private var requestID: UUID? + private var approvalID: UUID? + private var approvalTask: Task? + private var approvalResult: Result<[String], any Error>? + private var retiredApprovals: [Task] = [] private(set) var error: (any Error)? private var connectionID = UUID() private var round = 0 @@ -50,11 +54,13 @@ public nonisolated final class SSHKeyboardInteractiveExchange: @unchecked Sendab condition.lock() cancelled = true let pending = task + let approval = approvalTask result = .failure(CancellationError()) assertionResult = .failure(CancellationError()) condition.broadcast() condition.unlock() pending?.cancel() + approval?.cancel() } func checkCancellation() throws { @@ -64,6 +70,63 @@ public nonisolated final class SSHKeyboardInteractiveExchange: @unchecked Sendab if stopped { throw CancellationError() } } + func presentTailscaleApproval(_ banner: String) throws { + try checkTailscaleApproval() + guard allowsUserInteraction, let handler else { throw SSHInteractionRequired() } + endTailscaleApproval() + round += 1 + let challenge = SSHKeyboardInteractiveChallenge( + connectionID: connectionID, hostID: hostID, hostname: hostname, port: port, + username: username, round: round, name: "", instruction: banner, prompts: [], + isTailscaleApproval: true) + condition.lock() + defer { condition.unlock() } + guard !cancelled else { throw CancellationError() } + approvalID = challenge.id + approvalTask = Task.detached(priority: .userInitiated) { [weak self] in + let response: Result<[String], any Error> + do { + try Task.checkCancellation() + response = .success(try await handler(challenge)) + } catch { response = .failure(error) } + self?.finishApproval(challenge.id, result: response) + } + } + + func checkTailscaleApproval() throws { + try checkCancellation() + let response = condition.withLock { approvalResult } + // A responder's acknowledgement is never proof that the server accepted authentication. + if case .failure(let error) = response { throw error } + } + + func endTailscaleApproval() { + condition.lock() + let pending = approvalTask + approvalID = nil + approvalResult = nil + approvalTask = nil + if let pending { retiredApprovals.append(pending) } + condition.unlock() + // Retire the ID first so dismissing our own prompt cannot cancel a successful connection. + pending?.cancel() + } + + func waitForTailscaleApprovalDismissal() async { + let pending = condition.withLock { + let pending = retiredApprovals + retiredApprovals.removeAll() + return pending + } + for task in pending { await task.value } + } + + private func finishApproval(_ id: UUID, result: Result<[String], any Error>) { + condition.withLock { + if approvalID == id, !cancelled { approvalResult = result } + } + } + func answer(name: String, instruction: String, prompts: [SSHKeyboardInteractiveChallenge.Prompt]) throws -> [String] { try checkCancellation() diff --git a/Frameworks/TermindRemoteInfrastructure/SSH/SSHPTYChannel.swift b/Frameworks/TermindRemoteInfrastructure/SSH/SSHPTYChannel.swift index f526dc25..a7b645a2 100644 --- a/Frameworks/TermindRemoteInfrastructure/SSH/SSHPTYChannel.swift +++ b/Frameworks/TermindRemoteInfrastructure/SSH/SSHPTYChannel.swift @@ -1123,32 +1123,9 @@ nonisolated final class SSHPTYChannelWorker: @unchecked Sendable { throw TailscaleConfigurationError.hostKeyMismatch } progress?(.authenticating(configuration.hostID)) - let deadline = ContinuousClock.now.advanced(by: .seconds(600)) - var presentedBanners: Set = [] - let approvalConnectionID = UUID() - while true { - try Task.checkCancellation() - let step = try await queue.perform { - try state.authenticateTailscale(username: configuration.username) - } - if step.authenticated { break } - if let banner = step.banner, !banner.isEmpty, presentedBanners.insert(banner).inserted { - guard request.interactionPolicy == .allowed, let present = request.keyboardInteractive - else { - throw SSHInteractionRequired() - } - _ = try await present( - SSHKeyboardInteractiveChallenge( - connectionID: approvalConnectionID, hostID: configuration.hostID, - hostname: configuration.host, port: configuration.port, - username: configuration.username, - round: presentedBanners.count, name: "", instruction: banner, prompts: [], - isTailscaleApproval: true)) - } - guard ContinuousClock.now < deadline else { throw TailscaleConfigurationError.approvalTimedOut } - try await Task.sleep(for: .milliseconds(50)) + try await queue.perform { + try state.authenticateTailscale(username: configuration.username, exchange: exchange) } - progress?(.authenticated(configuration.hostID)) authentication = "tailscale" } else { try await authorizeHostKey( @@ -1166,6 +1143,9 @@ nonisolated final class SSHPTYChannelWorker: @unchecked Sendable { ) } } + await exchange.waitForTailscaleApprovalDismissal() + try Task.checkCancellation() + progress?(.authenticated(configuration.hostID)) let result = try await queue.perform { try state.takeResult() } logger.info( "ssh.authentication.completed", @@ -1176,6 +1156,7 @@ nonisolated final class SSHPTYChannelWorker: @unchecked Sendable { ]) return result } catch { + await exchange.waitForTailscaleApprovalDismissal() logger.error( "ssh.authentication.failed", metadata: [ @@ -1711,7 +1692,7 @@ nonisolated final class SSHPTYChannelWorker: @unchecked Sendable { keyboardInteractive: SSHKeyboardInteractiveExchange? = nil, socket: Int32 = -1, logger: Logging.Logger = ApplicationLogger.disabled("termind.ssh-authentication") ) throws -> String { - var methods = try authenticationMethods(username: username, session: session) + var methods = try authenticationMethods(username: username, session: session, exchange: keyboardInteractive) // Asking for the method list sends SSH's `none` authentication request. // Servers that need no credential accept it there and report the // session as authenticated without another userauth call. @@ -1745,7 +1726,7 @@ nonisolated final class SSHPTYChannelWorker: @unchecked Sendable { if let error = keyboardInteractive.error { throw error } if result == 0 { return lastMethod } failures.append(connectionErrorDetail(session, fallbackCode: result)) - methods = try authenticationMethods(username: username, session: session) + methods = try authenticationMethods(username: username, session: session, exchange: keyboardInteractive) if libssh2_userauth_authenticated(session) == 1 { return lastMethod } continue } @@ -1871,7 +1852,7 @@ nonisolated final class SSHPTYChannelWorker: @unchecked Sendable { case .interactionRequired: skippedInteractiveCredential = true } - methods = try authenticationMethods(username: username, session: session) + methods = try authenticationMethods(username: username, session: session, exchange: keyboardInteractive) if libssh2_userauth_authenticated(session) == 1 { return lastMethod } } if skippedInteractiveCredential { throw SSHInteractionRequired() } @@ -1901,7 +1882,11 @@ nonisolated final class SSHPTYChannelWorker: @unchecked Sendable { session: session, keyboardInteractive: keyboardInteractive, socket: socket, logger: logger ) } catch { - if error is CancellationError || keyboardInteractive?.error != nil { throw error } + if error is CancellationError || error is SSHInteractionRequired + || error is TailscaleConfigurationError || keyboardInteractive?.error != nil + { + throw error + } let code = libssh2_session_last_errno(session) if afterHostKeyApproval, code == LIBSSH2_ERROR_SOCKET_DISCONNECT @@ -1921,8 +1906,16 @@ nonisolated final class SSHPTYChannelWorker: @unchecked Sendable { } private static func authenticationMethods( - username: String, session: OpaquePointer + username: String, session: OpaquePointer, exchange: SSHKeyboardInteractiveExchange? ) throws -> Set { + let banner = libssh2_session_banner_get(session).map { String(cString: $0) } + if SSHTailscaleAuthentication.isTailscaleServer(banner) { + switch try SSHTailscaleAuthentication.query(username: username, session: session, exchange: exchange) { + case .authenticated: return [] + case .methods(let methods): return methods + case .pending: preconditionFailure("An authentication query must finish before returning.") + } + } let pointer = username.withCString { user in libssh2_userauth_list(session, user, UInt32(username.utf8.count)) } @@ -2825,32 +2818,17 @@ private nonisolated final class SSHAuthenticatedOpeningState: @unchecked Sendabl afterHostKeyApproval: afterHostKeyApproval, keyboardInteractive: keyboardInteractive, socket: transport?.socket ?? -1, logger: logger ) - progress?(.authenticated(configuration.hostID)) return authentication } - func authenticateTailscale(username: String) throws -> (authenticated: Bool, banner: String?) { + func authenticateTailscale(username: String, exchange: SSHKeyboardInteractiveExchange) throws { guard let session else { throw TermindSSHError.disconnected } - libssh2_session_set_blocking(session, 0) - _ = username.withCString { libssh2_userauth_list(session, $0, UInt32(username.utf8.count)) } - if libssh2_userauth_authenticated(session) != 0 { - libssh2_session_set_blocking(session, 1) - return (true, nil) + guard + try SSHTailscaleAuthentication.query(username: username, session: session, exchange: exchange) + == .authenticated + else { + throw SSHTailscaleAuthentication.failure(code: LIBSSH2_ERROR_AUTHENTICATION_FAILED) } - let code = libssh2_session_last_errno(session) - var message: UnsafeMutablePointer? - var length: Int32 = 0 - _ = libssh2_session_last_error(session, &message, &length, 0) - let failureDetail = message.map { String(cString: $0) } - var banner: UnsafeMutablePointer? - _ = libssh2_userauth_banner(session, &banner) - guard code == LIBSSH2_ERROR_EAGAIN else { - let detail = banner.map { String(cString: $0) } ?? failureDetail - let reason = String(localized: "Tailscale SSH authentication failed.") - throw TermindSSHError.authenticationFailed( - detail.map { "\(reason)\n\($0) [libssh2 \(code)]" } ?? "\(reason) [libssh2 \(code)]") - } - return (false, banner.map { String(cString: $0) }) } func takeResult() throws -> SSHAuthenticatedConnection { diff --git a/Frameworks/TermindRemoteInfrastructure/SSH/SSHRoutedSocket.swift b/Frameworks/TermindRemoteInfrastructure/SSH/SSHRoutedSocket.swift index 6f0766c2..4f11d9ed 100644 --- a/Frameworks/TermindRemoteInfrastructure/SSH/SSHRoutedSocket.swift +++ b/Frameworks/TermindRemoteInfrastructure/SSH/SSHRoutedSocket.swift @@ -100,6 +100,8 @@ public nonisolated final class SSHRoutedSocket: @unchecked Sendable { progress: progress, keyboardInteractive: exchange, logger: logger ) } + await exchange.waitForTailscaleApprovalDismissal() + try Task.checkCancellation() } catch is CancellationError { throw CancellationError() } catch let error as SSHInteractionRequired { @@ -111,6 +113,7 @@ public nonisolated final class SSHRoutedSocket: @unchecked Sendable { } return try await queue.perform { try state.takeResult() } } catch { + await exchange.waitForTailscaleApprovalDismissal() await queue.perform { state.cleanup() } throw error } diff --git a/Frameworks/TermindRemoteInfrastructure/SSH/SSHTailscaleAuthentication.swift b/Frameworks/TermindRemoteInfrastructure/SSH/SSHTailscaleAuthentication.swift new file mode 100644 index 00000000..2bf214df --- /dev/null +++ b/Frameworks/TermindRemoteInfrastructure/SSH/SSHTailscaleAuthentication.swift @@ -0,0 +1,80 @@ +import CSSH2 +import Foundation +import TermindApplication +import TermindDomain + +nonisolated enum SSHTailscaleAuthentication { + enum Step: Equatable, Sendable { + case authenticated + case methods(Set) + case pending(String?) + } + + static func isTailscaleServer(_ banner: String?) -> Bool { + guard let banner, banner.hasPrefix("SSH-2.0-") else { return false } + let software = banner.dropFirst("SSH-2.0-".count).split(whereSeparator: \.isWhitespace).first ?? "" + return software == "Tailscale" || software.hasPrefix("Tailscale_") + } + + static func query( + username: String, session: OpaquePointer, exchange: SSHKeyboardInteractiveExchange? + ) throws -> Step { + let blocking = libssh2_session_get_blocking(session) + let readTimeout = libssh2_session_get_read_timeout(session) + libssh2_session_set_blocking(session, 0) + // The application deadline owns approval expiry, not the default 60-second packet timeout. + libssh2_session_set_read_timeout(session, max(readTimeout, 601)) + defer { + libssh2_session_set_blocking(session, blocking) + libssh2_session_set_read_timeout(session, readTimeout) + } + return try run(exchange: exchange) { + let methods = username.withCString { libssh2_userauth_list(session, $0, UInt32(username.utf8.count)) } + if libssh2_userauth_authenticated(session) != 0 { return .authenticated } + if let methods { + return .methods(Set(String(cString: methods).split(separator: ",").map(String.init))) + } + // Reading a missing banner changes last_errno, so capture the authentication result first. + let code = libssh2_session_last_errno(session) + guard code == LIBSSH2_ERROR_EAGAIN else { + throw failure(code: code) + } + var banner: UnsafeMutablePointer? + _ = libssh2_userauth_banner(session, &banner) + return .pending(banner.map { String(cString: $0) }) + } + } + + static func run( + exchange: SSHKeyboardInteractiveExchange?, + timeout: Duration = .seconds(600), + now: () -> ContinuousClock.Instant = { .now }, + pause: () -> Void = { Thread.sleep(forTimeInterval: 0.05) }, + step: () throws -> Step + ) throws -> Step { + let deadline = now().advanced(by: timeout) + var presentedBanners: Set = [] + defer { exchange?.endTailscaleApproval() } + while true { + try exchange?.checkTailscaleApproval() + guard now() < deadline else { throw TailscaleConfigurationError.approvalTimedOut } + let result = try step() + try exchange?.checkTailscaleApproval() + switch result { + case .authenticated, .methods: + return result + case .pending(let banner): + if let banner, !banner.isEmpty, presentedBanners.insert(banner).inserted { + guard let exchange else { throw SSHInteractionRequired() } + try exchange.presentTailscaleApproval(banner) + } + pause() + } + } + } + + static func failure(code: Int32) -> TermindSSHError { + // Authentication banners may contain one-time approval URLs; keep them out of errors and logs. + .authenticationFailed(String(localized: "Tailscale SSH authentication failed.") + " [libssh2 \(code)]") + } +} diff --git a/Scripts/ssh-authentication-fixture.py b/Scripts/ssh-authentication-fixture.py index bd2111f0..9a699997 100644 --- a/Scripts/ssh-authentication-fixture.py +++ b/Scripts/ssh-authentication-fixture.py @@ -21,6 +21,67 @@ import time import paramiko +class Approvals: + def __init__(self): + self.lock = threading.Lock() + self.pending = {} + + @staticmethod + def banner(transport, username): + message = paramiko.Message() + message.add_byte(paramiko.common.cMSG_USERAUTH_BANNER) + message.add_string("Tailscale SSH requires approval.\nhttps://login.example/ssh/" + username) + message.add_string("en") + transport._send_message(message) + + def wait(self, transport, username): + state = {"event": threading.Event(), "accepted": False, "transport": transport} + with self.lock: + self.pending[username] = state + try: + self.banner(transport, username) + while transport.is_active() and not state["event"].wait(0.1): + pass + return paramiko.AUTH_SUCCESSFUL if state["accepted"] else paramiko.AUTH_FAILED + finally: + with self.lock: + self.pending.pop(username, None) + + def serve(self, listener): + while True: + try: + peer, _ = listener.accept() + except OSError: + return + with peer: + peer.settimeout(5) + try: + command = b"" + while not command.endswith(b"\n") and len(command) < 1024: + data = peer.recv(1024) + if not data: + break + command += data + action, username = command.decode("ascii").strip().split(" ", 1) + with self.lock: + state = self.pending.get(username) + if state is None: + peer.sendall(b"missing\n") + continue + if action == "repeat": + self.banner(state["transport"], username) + elif action in ("accept", "reject", "disconnect"): + state["accepted"] = action == "accept" + if action == "disconnect": + state["transport"].close() + state["event"].set() + else: + raise ValueError("Unknown fixture action") + peer.sendall(b"ok\n") + except (OSError, ValueError, EOFError, paramiko.SSHException): + pass + + class MemoryFile(paramiko.SFTPHandle): def __init__(self, data): super().__init__() @@ -101,7 +162,8 @@ class Files(paramiko.SFTPServerInterface): class Authentication(paramiko.ServerInterface): - def __init__(self, client_key): + def __init__(self, client_key, approvals=None): + self.approvals = approvals self.listeners = {} self.client_key = client_key self.user = "" @@ -111,10 +173,12 @@ class Authentication(paramiko.ServerInterface): self.shells = set() def get_allowed_auths(self, username): + if username.startswith("check-"): + return "" if self.factor: return "password" if username == "kbd-password" else "keyboard-interactive" return { - "password": "password", "password-mfa": "password", + "password": "password", "password-mfa": "password", "banner-password": "password", "publickey": "publickey", "encrypted-key": "publickey", "publickey-mfa": "publickey", "reject": "password", "key-password": "publickey,password", @@ -122,6 +186,10 @@ class Authentication(paramiko.ServerInterface): def check_auth_none(self, username): self.user = username + if self.approvals is not None and username.startswith("check-"): + return self.approvals.wait(self.transport, username) + if username == "banner-password": + Approvals.banner(self.transport, username) return paramiko.AUTH_SUCCESSFUL if username in ("none", "none-slow-forward") else paramiko.AUTH_FAILED def check_auth_password(self, username, password): @@ -130,7 +198,7 @@ class Authentication(paramiko.ServerInterface): if username == "password-mfa": self.factor = True return paramiko.AUTH_PARTIALLY_SUCCESSFUL - accepted = username in ("password", "key-password") or (username == "kbd-password" and self.factor) + accepted = username in ("password", "key-password", "banner-password") or (username == "kbd-password" and self.factor) return paramiko.AUTH_SUCCESSFUL if accepted else paramiko.AUTH_FAILED def check_auth_publickey(self, username, key): @@ -276,16 +344,20 @@ def relay(channel, destination): channel.close() -def serve(sock, host_key, client_key, port, echo_port): +def serve(sock, host_key, client_key, port, echo_port, approvals=None, tailscale_port=None): transport = paramiko.Transport(sock) + if approvals is not None: + transport.local_version = "SSH-2.0-Tailscale" transport.banner_timeout = 120 transport.auth_timeout = 120 transport.add_server_key(host_key) transport.set_subsystem_handler("sftp", paramiko.SFTPServer, Files) - server = Authentication(client_key) + server = Authentication(client_key, approvals) server.port = port server.transport = transport server.allowed_destinations = {("127.0.0.1", port), ("127.0.0.1", echo_port), ("::1", echo_port)} + if tailscale_port is not None: + server.allowed_destinations.add(("127.0.0.1", tailscale_port)) channels = [] try: transport.start_server(server=server) @@ -315,7 +387,8 @@ def main(): client_key.write_private_key(private) encrypted_private = io.StringIO() client_key.write_private_key(encrypted_private, password="fixture-passphrase") - with socket.socket() as listener, socket.socket() as echo_listener: + with (socket.socket() as listener, socket.socket() as echo_listener, + socket.socket() as tailscale_listener, socket.socket() as approval_listener): echo_listener.bind(("127.0.0.1", 0)) echo_listener.listen(128) echo_port = echo_listener.getsockname()[1] @@ -323,10 +396,31 @@ def main(): listener.bind(("127.0.0.1", 0)) listener.listen(128) port = listener.getsockname()[1] + tailscale_listener.bind(("127.0.0.1", 0)) + tailscale_listener.listen(128) + tailscale_port = tailscale_listener.getsockname()[1] + approval_listener.bind(("127.0.0.1", 0)) + approval_listener.listen(128) + approvals = Approvals() + threading.Thread(target=approvals.serve, args=(approval_listener,), daemon=True).start() + + def accept_tailscale(): + while True: + try: + sock, _ = tailscale_listener.accept() + except OSError: + return + threading.Thread( + target=serve, + args=(sock, host_key, client_key, port, echo_port, approvals, tailscale_port), + daemon=True).start() + threading.Thread(target=accept_tailscale, daemon=True).start() fingerprint = base64.b64encode(hashlib.sha256(host_key.asbytes()).digest()).decode().rstrip("=") metadata = { "port": port, "echoPort": echo_port, + "tailscalePort": tailscale_port, + "approvalPort": approval_listener.getsockname()[1], "privateKey": private.getvalue(), "encryptedPrivateKey": encrypted_private.getvalue(), "hostFingerprint": "SHA256:" + fingerprint, @@ -339,7 +433,9 @@ def main(): try: while True: sock, _ = listener.accept() - threading.Thread(target=serve, args=(sock, host_key, client_key, port, echo_port), daemon=True).start() + threading.Thread( + target=serve, args=(sock, host_key, client_key, port, echo_port, None, tailscale_port), + daemon=True).start() except KeyboardInterrupt: pass finally: