Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
40 changes: 40 additions & 0 deletions build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -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()
}
}
17 changes: 17 additions & 0 deletions spotless/license-header.txt
Original file line number Diff line number Diff line change
@@ -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.
*/

32 changes: 0 additions & 32 deletions sshlib/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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 {
Expand Down
9 changes: 8 additions & 1 deletion sshlib/src/main/kotlin/org/connectbot/sshlib/AuthHandler.kt
Original file line number Diff line number Diff line change
@@ -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.
Expand Down Expand Up @@ -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) {}
}

/**
Expand Down
Original file line number Diff line number Diff line change
@@ -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.
Expand Down Expand Up @@ -27,4 +28,5 @@ internal sealed class InternalAuthResult {
val instruction: String,
val prompts: List<KeyboardInteractiveCallback.Prompt>,
) : InternalAuthResult()
data class Banner(val message: String) : InternalAuthResult()
}
Original file line number Diff line number Diff line change
@@ -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.
Expand Down Expand Up @@ -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()
}
Comment on lines +747 to +751
if (noneResult is InternalAuthResult.Success) return PublicAuthResult.Success
if (noneResult !is InternalAuthResult.Failure) return PublicAuthResult.Error("Unexpected response to 'none' auth: $noneResult")

Expand All @@ -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)
Expand All @@ -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
}

Expand All @@ -801,6 +806,7 @@ class SshConnection(
private suspend fun probePublicKey(
username: String,
key: AuthPublicKey,
handler: AuthHandler,
channel: Channel<InternalAuthResult>,
): InternalAuthResult {
val effectiveAlgorithmName = if (keyBlobAlgorithmName(key.publicKeyBlob) == "ssh-rsa") {
Expand All @@ -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(
Expand Down Expand Up @@ -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
}
Expand All @@ -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 -> {
Expand Down Expand Up @@ -933,6 +953,7 @@ class SshConnection(
private suspend fun doPasswordAuth(
username: String,
password: String,
handler: AuthHandler,
channel: Channel<InternalAuthResult>,
): Boolean {
sendAuthRequest(username, "password") {
Expand All @@ -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 -> {
Expand Down Expand Up @@ -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")
}
Comment on lines +1653 to +1658
} else {
logger.info("SSH banner: ${msg.message().value()}")
}
}

private fun debug(msg: SshMsgDebug) {
Expand Down
Original file line number Diff line number Diff line change
@@ -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.
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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<String>, 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())
}
Comment on lines +552 to +573
}
}

suspend fun awaitPong(): ByteArray = receivedPongs.receive()

suspend fun awaitExtInfo(): SshMsgExtInfo = receivedExtInfo.receive()
Expand Down
Loading
Loading