From ea099fc5a608b22fb80ab908deccf34b7cc999d2 Mon Sep 17 00:00:00 2001 From: Kaede Akino Date: Sat, 17 Oct 2026 06:31:55 +0800 Subject: [PATCH] fix(ssh): support Tailscale approval on ordinary connections Detect Tailscale SSH by server identification and authenticate with the configured username without requiring credentials or Pro. Keep browser approval concurrent with transport polling and dismiss prompts when authentication finishes. Preserve managed Tailnet access limits and add loopback regression coverage. --- .../SSHTailscaleAuthenticationTests.swift | 207 ++++++++++++++ FrameworkTests/TailscaleSSHWireTests.swift | 253 ++++++++++++++++++ .../SSHKeyboardInteractiveChallenge.swift | 3 + .../SSHKeyboardInteractiveForm.swift | 13 +- .../SSH/SSHKeyboardInteractiveExchange.swift | 63 +++++ .../SSH/SSHPTYChannel.swift | 80 ++---- .../SSH/SSHRoutedSocket.swift | 3 + .../SSH/SSHTailscaleAuthentication.swift | 80 ++++++ Scripts/ssh-authentication-fixture.py | 110 +++++++- 9 files changed, 751 insertions(+), 61 deletions(-) create mode 100644 FrameworkTests/SSHTailscaleAuthenticationTests.swift create mode 100644 Frameworks/TermindRemoteInfrastructure/SSH/SSHTailscaleAuthentication.swift 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: