diff --git a/build.gradle.kts b/build.gradle.kts index 4825eeb..5da3a85 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -36,3 +36,43 @@ allprojects { dependencies { kover(project(":sshlib")) } + +spotless { + ratchetFrom = "origin/main" + + kotlin { + target("**/src/**/*.kt") + ktlint("1.8.0") + licenseHeaderFile("spotless/license-header.txt") + } + + kotlinGradle { + target("**/*.gradle.kts") + ktlint("1.8.0") + } + + yaml { + target(".github/**/*.yml", ".github/**/*.yaml") + trimTrailingWhitespace() + endWithNewline() + } + + format("xml") { + target("**/*.xml") + targetExclude("**/.idea/**/*.xml", "**/bin/**/*.xml", "**/build/**/*.xml", "**/.worktrees/**/*.xml") + trimTrailingWhitespace() + endWithNewline() + } + + format("toml") { + target("**/*.toml") + trimTrailingWhitespace() + endWithNewline() + } + + format("misc") { + target(listOf("**/*.md", "**/.gitignore", "**/.gitattributes", "**/.editorconfig")) + trimTrailingWhitespace() + endWithNewline() + } +} diff --git a/spotless/license-header.txt b/spotless/license-header.txt new file mode 100644 index 0000000..9770bed --- /dev/null +++ b/spotless/license-header.txt @@ -0,0 +1,17 @@ +/* + * ConnectBot SSH Library + * Copyright $YEAR Kenny Root + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + diff --git a/sshlib/build.gradle.kts b/sshlib/build.gradle.kts index 7587bdd..d540601 100644 --- a/sshlib/build.gradle.kts +++ b/sshlib/build.gradle.kts @@ -19,7 +19,6 @@ import org.jetbrains.kotlin.gradle.dsl.JvmTarget plugins { alias(libs.plugins.kotlin.jvm) - alias(libs.plugins.spotless) alias(libs.plugins.publish) alias(libs.plugins.dokka) alias(libs.plugins.metalava) @@ -81,37 +80,6 @@ kotlin { } } -spotless { - kotlinGradle { - target( - fileTree(".") { - include("**/*.gradle.kts") - exclude("**/build", "**/out") - }, - ) - ktlint() - } - - kotlin { - ktlint("1.8.0") - } - - format("xml") { - target( - fileTree(".") { - include("config/**/*.xml", "lib/**/*.xml", "test-app/**/*.xml") - exclude("**/build", "**/out") - }, - ) - } - - format("misc") { - target("**/.gitignore") - trimTrailingWhitespace() - endWithNewline() - } -} - val gitHubUrl = "https://github.com/kruton/ssh-proto" dokka { diff --git a/sshlib/src/main/kotlin/org/connectbot/sshlib/AuthHandler.kt b/sshlib/src/main/kotlin/org/connectbot/sshlib/AuthHandler.kt index 39f6672..21d8031 100644 --- a/sshlib/src/main/kotlin/org/connectbot/sshlib/AuthHandler.kt +++ b/sshlib/src/main/kotlin/org/connectbot/sshlib/AuthHandler.kt @@ -1,5 +1,6 @@ /* - * Copyright 2025 Kenny Root + * ConnectBot SSH Library + * Copyright 2025-2026 Kenny Root * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -70,6 +71,12 @@ interface AuthHandler { * Return the password, or null to skip password auth. */ suspend fun onPasswordNeeded(): String? + + /** + * Called when the server sends an authentication banner (SSH_MSG_USERAUTH_BANNER). + * This is often used for out-of-band authentication instructions (e.g., a URL to visit). + */ + suspend fun onBanner(message: String) {} } /** diff --git a/sshlib/src/main/kotlin/org/connectbot/sshlib/client/AuthResult.kt b/sshlib/src/main/kotlin/org/connectbot/sshlib/client/AuthResult.kt index 9b121b6..29149c3 100644 --- a/sshlib/src/main/kotlin/org/connectbot/sshlib/client/AuthResult.kt +++ b/sshlib/src/main/kotlin/org/connectbot/sshlib/client/AuthResult.kt @@ -1,5 +1,6 @@ /* - * Copyright 2025 Kenny Root + * ConnectBot SSH Library + * Copyright 2025-2026 Kenny Root * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -27,4 +28,5 @@ internal sealed class InternalAuthResult { val instruction: String, val prompts: List, ) : InternalAuthResult() + data class Banner(val message: String) : InternalAuthResult() } diff --git a/sshlib/src/main/kotlin/org/connectbot/sshlib/client/SshConnection.kt b/sshlib/src/main/kotlin/org/connectbot/sshlib/client/SshConnection.kt index 1641062..e14d755 100644 --- a/sshlib/src/main/kotlin/org/connectbot/sshlib/client/SshConnection.kt +++ b/sshlib/src/main/kotlin/org/connectbot/sshlib/client/SshConnection.kt @@ -1,5 +1,6 @@ /* - * Copyright 2025 Kenny Root + * ConnectBot SSH Library + * Copyright 2025-2026 Kenny Root * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -743,7 +744,11 @@ class SshConnection( setMethodSpecificFields(noneAuth) } - val noneResult = channel.receive() + var noneResult = channel.receive() + while (noneResult is InternalAuthResult.Banner) { + handler.onBanner(noneResult.message) + noneResult = channel.receive() + } if (noneResult is InternalAuthResult.Success) return PublicAuthResult.Success if (noneResult !is InternalAuthResult.Failure) return PublicAuthResult.Error("Unexpected response to 'none' auth: $noneResult") @@ -758,7 +763,7 @@ class SshConnection( val keys = handler.onPublicKeysNeeded() for (key in keys) { if (key in triedPublicKeys) continue - val probeResult = probePublicKey(username, key, channel) + val probeResult = probePublicKey(username, key, handler, channel) if (probeResult is InternalAuthResult.Success) return PublicAuthResult.Success if (probeResult is InternalAuthResult.PkOk) { triedPublicKeys.add(key) @@ -783,7 +788,7 @@ class SshConnection( is AuthMethod.Password -> { val password = handler.onPasswordNeeded() ?: return PublicAuthResult.Failure(allowedAuthentications ?: emptySet()) - val passResult = doPasswordAuth(username, password, channel) + val passResult = doPasswordAuth(username, password, handler, channel) if (passResult) return PublicAuthResult.Success } @@ -801,6 +806,7 @@ class SshConnection( private suspend fun probePublicKey( username: String, key: AuthPublicKey, + handler: AuthHandler, channel: Channel, ): InternalAuthResult { val effectiveAlgorithmName = if (keyBlobAlgorithmName(key.publicKeyBlob) == "ssh-rsa") { @@ -817,7 +823,12 @@ class SshConnection( } setMethodSpecificFields(pubkeyAuth) } - return channel.receive() + var response = channel.receive() + while (response is InternalAuthResult.Banner) { + handler.onBanner(response.message) + response = channel.receive() + } + return response } private suspend fun signPublicKey( @@ -870,7 +881,12 @@ class SshConnection( } } - return when (channel.receive()) { + var response = channel.receive() + while (response is InternalAuthResult.Banner) { + handler.onBanner(response.message) + response = channel.receive() + } + return when (response) { is InternalAuthResult.Success -> true else -> false } @@ -892,6 +908,10 @@ class SshConnection( while (true) { when (val result = channel.receive()) { + is InternalAuthResult.Banner -> { + handler.onBanner(result.message) + } + is InternalAuthResult.Success -> return true is InternalAuthResult.Failure -> { @@ -933,6 +953,7 @@ class SshConnection( private suspend fun doPasswordAuth( username: String, password: String, + handler: AuthHandler, channel: Channel, ): Boolean { sendAuthRequest(username, "password") { @@ -944,7 +965,12 @@ class SshConnection( setMethodSpecificFields(passAuth) } - return when (val result = channel.receive()) { + var response = channel.receive() + while (response is InternalAuthResult.Banner) { + handler.onBanner(response.message) + response = channel.receive() + } + return when (val result = response) { is InternalAuthResult.Success -> true is InternalAuthResult.Failure -> { @@ -1624,7 +1650,15 @@ class SshConnection( } private fun receiveUserauthBanner(msg: SshMsgUserauthBanner) { - logger.info("SSH banner: ${msg.message().value()}") + val ch = authResultChannel + if (ch != null) { + val message = msg.message().value() + if (ch.trySend(InternalAuthResult.Banner(message)).isFailure) { + logger.warn("Failed to deliver banner to auth channel") + } + } else { + logger.info("SSH banner: ${msg.message().value()}") + } } private fun debug(msg: SshMsgDebug) { diff --git a/sshlib/src/test/kotlin/org/connectbot/sshlib/client/FakeSshServer.kt b/sshlib/src/test/kotlin/org/connectbot/sshlib/client/FakeSshServer.kt index 6f03941..933836a 100644 --- a/sshlib/src/test/kotlin/org/connectbot/sshlib/client/FakeSshServer.kt +++ b/sshlib/src/test/kotlin/org/connectbot/sshlib/client/FakeSshServer.kt @@ -1,5 +1,6 @@ /* - * Copyright 2025 Kenny Root + * ConnectBot SSH Library + * Copyright 2025-2026 Kenny Root * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -43,10 +44,13 @@ import org.connectbot.sshlib.protocol.SshMsgKexinit import org.connectbot.sshlib.protocol.SshMsgPing import org.connectbot.sshlib.protocol.SshMsgPong import org.connectbot.sshlib.protocol.SshMsgServiceAccept +import org.connectbot.sshlib.protocol.SshMsgUserauthBanner +import org.connectbot.sshlib.protocol.SshMsgUserauthFailure import org.connectbot.sshlib.protocol.SshMsgUserauthRequest import org.connectbot.sshlib.protocol.createAsciiString import org.connectbot.sshlib.protocol.createByteString import org.connectbot.sshlib.protocol.createNameList +import org.connectbot.sshlib.protocol.createUtf8String import org.connectbot.sshlib.protocol.toByteArray import org.connectbot.sshlib.transport.PacketIO import org.connectbot.sshlib.transport.PipedTransport @@ -545,6 +549,31 @@ class FakeSshServer( } } + fun sendUserauthBanner(message: String) { + scope.launch(coroutineContext) { + val banner = SshMsgUserauthBanner() + val utf8 = createUtf8String(message) + banner.setMessage(utf8) + banner.setLanguageTag(createByteString(ByteArray(0))) + banner._check() + writeMutex.withLock { + serverIo.writePacket(SshEnums.MessageType.SSH_MSG_USERAUTH_BANNER.id().toInt(), banner.toByteArray()) + } + } + } + + fun sendUserauthFailure(allowedMethods: Set, partialSuccess: Boolean) { + scope.launch(coroutineContext) { + val failure = SshMsgUserauthFailure() + failure.setValidAuthentications(createNameList(allowedMethods.joinToString(","))) + failure.setPartialSuccess(if (partialSuccess) 1 else 0) + failure._check() + writeMutex.withLock { + serverIo.writePacket(SshEnums.MessageType.SSH_MSG_USERAUTH_FAILURE.id().toInt(), failure.toByteArray()) + } + } + } + suspend fun awaitPong(): ByteArray = receivedPongs.receive() suspend fun awaitExtInfo(): SshMsgExtInfo = receivedExtInfo.receive() diff --git a/sshlib/src/test/kotlin/org/connectbot/sshlib/client/SshAuthBannerTest.kt b/sshlib/src/test/kotlin/org/connectbot/sshlib/client/SshAuthBannerTest.kt new file mode 100644 index 0000000..7cb8c03 --- /dev/null +++ b/sshlib/src/test/kotlin/org/connectbot/sshlib/client/SshAuthBannerTest.kt @@ -0,0 +1,114 @@ +/* + * ConnectBot SSH Library + * Copyright 2026 Kenny Root + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.connectbot.sshlib.client + +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.yield +import org.connectbot.sshlib.AuthHandler +import org.connectbot.sshlib.AuthPublicKey +import org.connectbot.sshlib.AuthResult +import org.connectbot.sshlib.ConnectResult +import org.connectbot.sshlib.HostKeyVerifier +import org.connectbot.sshlib.KeyboardInteractiveCallback +import org.connectbot.sshlib.PublicKey +import org.connectbot.sshlib.transport.PipedTransport +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Test +import kotlin.test.assertIs + +@OptIn(ExperimentalCoroutinesApi::class) +class SshAuthBannerTest { + + private val acceptAllVerifier = object : HostKeyVerifier { + override suspend fun verify(key: PublicKey): Boolean = true + } + + private suspend fun connectInBackground( + connection: SshConnection, + backgroundScope: CoroutineScope, + dispatcher: CoroutineDispatcher, + ): ConnectResult { + val result = CompletableDeferred() + backgroundScope.launch(dispatcher) { result.complete(connection.connect()) } + yield() + return result.await() + } + + @Test + fun `onBanner is called when server sends banner during none auth`() = runTest { + val dispatcher = StandardTestDispatcher(testScheduler) + val (clientTransport, serverTransport) = PipedTransport.create() + val server = FakeSshServer(serverTransport, backgroundScope, dispatcher) + server.start() + + val connection = SshConnection( + transport = clientTransport, + hostKeyVerifier = acceptAllVerifier, + coroutineDispatcher = dispatcher, + ) + + val bannerReceived = CompletableDeferred() + val handler = object : AuthHandler { + override suspend fun onAuthMethodsAvailable(methods: Set) {} + override suspend fun onPublicKeysNeeded(): List = emptyList() + override suspend fun onSignatureRequest(key: AuthPublicKey, dataToSign: ByteArray): ByteArray? = null + override suspend fun onKeyboardInteractivePrompt( + name: String, + instruction: String, + prompts: List, + ): List? = null + override suspend fun onPasswordNeeded(): String? = null + + override suspend fun onBanner(message: String) { + bannerReceived.complete(message) + } + } + + try { + val connectResult = connectInBackground(connection, backgroundScope, dispatcher) + assertIs(connectResult) + + val authJob = backgroundScope.launch(dispatcher) { + // none auth will fail on fake server usually, but it will wait for the result + connection.authenticate("user", handler) + } + + // Wait for server to receive the "none" auth request + // FakeSshServer doesn't have a way to await auth request easily, but we can just wait a bit or use yield + yield() + + val bannerText = "Welcome to the test server! Visit https://example.com/auth" + server.sendUserauthBanner(bannerText) + + // Now fail the auth so authenticate() returns + server.sendUserauthFailure(setOf("password"), false) + + authJob.join() + + assertEquals(bannerText, bannerReceived.await()) + } finally { + connection.close() + } + } +}