Skip to content

Commit d0ebd20

Browse files
committed
feat: handle banners during auth
Some authentication methods use out-of-band authentication where the user is prompted to take action using the SSH_MSG_USERAUTH_BANNER message from the server. Support this by adding a new onBanner callback to the AuthHandler.
1 parent 521eae9 commit d0ebd20

5 files changed

Lines changed: 197 additions & 11 deletions

File tree

sshlib/src/main/kotlin/org/connectbot/sshlib/AuthHandler.kt

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
/*
2-
* Copyright 2025 Kenny Root
2+
* ConnectBot SSH Library
3+
* Copyright 2025-2026 Kenny Root
34
*
45
* Licensed under the Apache License, Version 2.0 (the "License");
56
* you may not use this file except in compliance with the License.
@@ -70,6 +71,12 @@ interface AuthHandler {
7071
* Return the password, or null to skip password auth.
7172
*/
7273
suspend fun onPasswordNeeded(): String?
74+
75+
/**
76+
* Called when the server sends an authentication banner (SSH_MSG_USERAUTH_BANNER).
77+
* This is often used for out-of-band authentication instructions (e.g., a URL to visit).
78+
*/
79+
suspend fun onBanner(message: String) {}
7380
}
7481

7582
/**

sshlib/src/main/kotlin/org/connectbot/sshlib/client/AuthResult.kt

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
/*
2-
* Copyright 2025 Kenny Root
2+
* ConnectBot SSH Library
3+
* Copyright 2025-2026 Kenny Root
34
*
45
* Licensed under the Apache License, Version 2.0 (the "License");
56
* you may not use this file except in compliance with the License.
@@ -27,4 +28,5 @@ internal sealed class InternalAuthResult {
2728
val instruction: String,
2829
val prompts: List<KeyboardInteractiveCallback.Prompt>,
2930
) : InternalAuthResult()
31+
data class Banner(val message: String) : InternalAuthResult()
3032
}

sshlib/src/main/kotlin/org/connectbot/sshlib/client/SshConnection.kt

Lines changed: 42 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
/*
2-
* Copyright 2025 Kenny Root
2+
* ConnectBot SSH Library
3+
* Copyright 2025-2026 Kenny Root
34
*
45
* Licensed under the Apache License, Version 2.0 (the "License");
56
* you may not use this file except in compliance with the License.
@@ -743,7 +744,11 @@ class SshConnection(
743744
setMethodSpecificFields(noneAuth)
744745
}
745746

746-
val noneResult = channel.receive()
747+
var noneResult = channel.receive()
748+
while (noneResult is InternalAuthResult.Banner) {
749+
handler.onBanner(noneResult.message)
750+
noneResult = channel.receive()
751+
}
747752
if (noneResult is InternalAuthResult.Success) return PublicAuthResult.Success
748753
if (noneResult !is InternalAuthResult.Failure) return PublicAuthResult.Error("Unexpected response to 'none' auth: $noneResult")
749754

@@ -758,7 +763,7 @@ class SshConnection(
758763
val keys = handler.onPublicKeysNeeded()
759764
for (key in keys) {
760765
if (key in triedPublicKeys) continue
761-
val probeResult = probePublicKey(username, key, channel)
766+
val probeResult = probePublicKey(username, key, handler, channel)
762767
if (probeResult is InternalAuthResult.Success) return PublicAuthResult.Success
763768
if (probeResult is InternalAuthResult.PkOk) {
764769
triedPublicKeys.add(key)
@@ -783,7 +788,7 @@ class SshConnection(
783788

784789
is AuthMethod.Password -> {
785790
val password = handler.onPasswordNeeded() ?: return PublicAuthResult.Failure(allowedAuthentications ?: emptySet())
786-
val passResult = doPasswordAuth(username, password, channel)
791+
val passResult = doPasswordAuth(username, password, handler, channel)
787792
if (passResult) return PublicAuthResult.Success
788793
}
789794

@@ -801,6 +806,7 @@ class SshConnection(
801806
private suspend fun probePublicKey(
802807
username: String,
803808
key: AuthPublicKey,
809+
handler: AuthHandler,
804810
channel: Channel<InternalAuthResult>,
805811
): InternalAuthResult {
806812
val effectiveAlgorithmName = if (keyBlobAlgorithmName(key.publicKeyBlob) == "ssh-rsa") {
@@ -817,7 +823,12 @@ class SshConnection(
817823
}
818824
setMethodSpecificFields(pubkeyAuth)
819825
}
820-
return channel.receive()
826+
var response = channel.receive()
827+
while (response is InternalAuthResult.Banner) {
828+
handler.onBanner(response.message)
829+
response = channel.receive()
830+
}
831+
return response
821832
}
822833

823834
private suspend fun signPublicKey(
@@ -870,7 +881,12 @@ class SshConnection(
870881
}
871882
}
872883

873-
return when (channel.receive()) {
884+
var response = channel.receive()
885+
while (response is InternalAuthResult.Banner) {
886+
handler.onBanner(response.message)
887+
response = channel.receive()
888+
}
889+
return when (response) {
874890
is InternalAuthResult.Success -> true
875891
else -> false
876892
}
@@ -892,6 +908,10 @@ class SshConnection(
892908

893909
while (true) {
894910
when (val result = channel.receive()) {
911+
is InternalAuthResult.Banner -> {
912+
handler.onBanner(result.message)
913+
}
914+
895915
is InternalAuthResult.Success -> return true
896916

897917
is InternalAuthResult.Failure -> {
@@ -933,6 +953,7 @@ class SshConnection(
933953
private suspend fun doPasswordAuth(
934954
username: String,
935955
password: String,
956+
handler: AuthHandler,
936957
channel: Channel<InternalAuthResult>,
937958
): Boolean {
938959
sendAuthRequest(username, "password") {
@@ -944,7 +965,12 @@ class SshConnection(
944965
setMethodSpecificFields(passAuth)
945966
}
946967

947-
return when (val result = channel.receive()) {
968+
var response = channel.receive()
969+
while (response is InternalAuthResult.Banner) {
970+
handler.onBanner(response.message)
971+
response = channel.receive()
972+
}
973+
return when (val result = response) {
948974
is InternalAuthResult.Success -> true
949975

950976
is InternalAuthResult.Failure -> {
@@ -1624,7 +1650,15 @@ class SshConnection(
16241650
}
16251651

16261652
private fun receiveUserauthBanner(msg: SshMsgUserauthBanner) {
1627-
logger.info("SSH banner: ${msg.message().value()}")
1653+
val ch = authResultChannel
1654+
if (ch != null) {
1655+
val message = msg.message().value()
1656+
if (ch.trySend(InternalAuthResult.Banner(message)).isFailure) {
1657+
logger.warn("Failed to deliver banner to auth channel")
1658+
}
1659+
} else {
1660+
logger.info("SSH banner: ${msg.message().value()}")
1661+
}
16281662
}
16291663

16301664
private fun debug(msg: SshMsgDebug) {

sshlib/src/test/kotlin/org/connectbot/sshlib/client/FakeSshServer.kt

Lines changed: 30 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
/*
2-
* Copyright 2025 Kenny Root
2+
* ConnectBot SSH Library
3+
* Copyright 2025-2026 Kenny Root
34
*
45
* Licensed under the Apache License, Version 2.0 (the "License");
56
* you may not use this file except in compliance with the License.
@@ -43,10 +44,13 @@ import org.connectbot.sshlib.protocol.SshMsgKexinit
4344
import org.connectbot.sshlib.protocol.SshMsgPing
4445
import org.connectbot.sshlib.protocol.SshMsgPong
4546
import org.connectbot.sshlib.protocol.SshMsgServiceAccept
47+
import org.connectbot.sshlib.protocol.SshMsgUserauthBanner
48+
import org.connectbot.sshlib.protocol.SshMsgUserauthFailure
4649
import org.connectbot.sshlib.protocol.SshMsgUserauthRequest
4750
import org.connectbot.sshlib.protocol.createAsciiString
4851
import org.connectbot.sshlib.protocol.createByteString
4952
import org.connectbot.sshlib.protocol.createNameList
53+
import org.connectbot.sshlib.protocol.createUtf8String
5054
import org.connectbot.sshlib.protocol.toByteArray
5155
import org.connectbot.sshlib.transport.PacketIO
5256
import org.connectbot.sshlib.transport.PipedTransport
@@ -545,6 +549,31 @@ class FakeSshServer(
545549
}
546550
}
547551

552+
fun sendUserauthBanner(message: String) {
553+
scope.launch(coroutineContext) {
554+
val banner = SshMsgUserauthBanner()
555+
val utf8 = createUtf8String(message)
556+
banner.setMessage(utf8)
557+
banner.setLanguageTag(createByteString(ByteArray(0)))
558+
banner._check()
559+
writeMutex.withLock {
560+
serverIo.writePacket(SshEnums.MessageType.SSH_MSG_USERAUTH_BANNER.id().toInt(), banner.toByteArray())
561+
}
562+
}
563+
}
564+
565+
fun sendUserauthFailure(allowedMethods: Set<String>, partialSuccess: Boolean) {
566+
scope.launch(coroutineContext) {
567+
val failure = SshMsgUserauthFailure()
568+
failure.setValidAuthentications(createNameList(allowedMethods.joinToString(",")))
569+
failure.setPartialSuccess(if (partialSuccess) 1 else 0)
570+
failure._check()
571+
writeMutex.withLock {
572+
serverIo.writePacket(SshEnums.MessageType.SSH_MSG_USERAUTH_FAILURE.id().toInt(), failure.toByteArray())
573+
}
574+
}
575+
}
576+
548577
suspend fun awaitPong(): ByteArray = receivedPongs.receive()
549578

550579
suspend fun awaitExtInfo(): SshMsgExtInfo = receivedExtInfo.receive()
Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,114 @@
1+
/*
2+
* ConnectBot SSH Library
3+
* Copyright 2026 Kenny Root
4+
*
5+
* Licensed under the Apache License, Version 2.0 (the "License");
6+
* you may not use this file except in compliance with the License.
7+
* You may obtain a copy of the License at
8+
*
9+
* http://www.apache.org/licenses/LICENSE-2.0
10+
*
11+
* Unless required by applicable law or agreed to in writing, software
12+
* distributed under the License is distributed on an "AS IS" BASIS,
13+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14+
* See the License for the specific language governing permissions and
15+
* limitations under the License.
16+
*/
17+
18+
package org.connectbot.sshlib.client
19+
20+
import kotlinx.coroutines.CompletableDeferred
21+
import kotlinx.coroutines.CoroutineDispatcher
22+
import kotlinx.coroutines.CoroutineScope
23+
import kotlinx.coroutines.ExperimentalCoroutinesApi
24+
import kotlinx.coroutines.launch
25+
import kotlinx.coroutines.test.StandardTestDispatcher
26+
import kotlinx.coroutines.test.runTest
27+
import kotlinx.coroutines.yield
28+
import org.connectbot.sshlib.AuthHandler
29+
import org.connectbot.sshlib.AuthPublicKey
30+
import org.connectbot.sshlib.AuthResult
31+
import org.connectbot.sshlib.ConnectResult
32+
import org.connectbot.sshlib.HostKeyVerifier
33+
import org.connectbot.sshlib.KeyboardInteractiveCallback
34+
import org.connectbot.sshlib.PublicKey
35+
import org.connectbot.sshlib.transport.PipedTransport
36+
import org.junit.jupiter.api.Assertions.assertEquals
37+
import org.junit.jupiter.api.Test
38+
import kotlin.test.assertIs
39+
40+
@OptIn(ExperimentalCoroutinesApi::class)
41+
class SshAuthBannerTest {
42+
43+
private val acceptAllVerifier = object : HostKeyVerifier {
44+
override suspend fun verify(key: PublicKey): Boolean = true
45+
}
46+
47+
private suspend fun connectInBackground(
48+
connection: SshConnection,
49+
backgroundScope: CoroutineScope,
50+
dispatcher: CoroutineDispatcher,
51+
): ConnectResult {
52+
val result = CompletableDeferred<ConnectResult>()
53+
backgroundScope.launch(dispatcher) { result.complete(connection.connect()) }
54+
yield()
55+
return result.await()
56+
}
57+
58+
@Test
59+
fun `onBanner is called when server sends banner during none auth`() = runTest {
60+
val dispatcher = StandardTestDispatcher(testScheduler)
61+
val (clientTransport, serverTransport) = PipedTransport.create()
62+
val server = FakeSshServer(serverTransport, backgroundScope, dispatcher)
63+
server.start()
64+
65+
val connection = SshConnection(
66+
transport = clientTransport,
67+
hostKeyVerifier = acceptAllVerifier,
68+
coroutineDispatcher = dispatcher,
69+
)
70+
71+
val bannerReceived = CompletableDeferred<String>()
72+
val handler = object : AuthHandler {
73+
override suspend fun onAuthMethodsAvailable(methods: Set<String>) {}
74+
override suspend fun onPublicKeysNeeded(): List<AuthPublicKey> = emptyList()
75+
override suspend fun onSignatureRequest(key: AuthPublicKey, dataToSign: ByteArray): ByteArray? = null
76+
override suspend fun onKeyboardInteractivePrompt(
77+
name: String,
78+
instruction: String,
79+
prompts: List<KeyboardInteractiveCallback.Prompt>,
80+
): List<String>? = null
81+
override suspend fun onPasswordNeeded(): String? = null
82+
83+
override suspend fun onBanner(message: String) {
84+
bannerReceived.complete(message)
85+
}
86+
}
87+
88+
try {
89+
val connectResult = connectInBackground(connection, backgroundScope, dispatcher)
90+
assertIs<ConnectResult.Success>(connectResult)
91+
92+
val authJob = backgroundScope.launch(dispatcher) {
93+
// none auth will fail on fake server usually, but it will wait for the result
94+
connection.authenticate("user", handler)
95+
}
96+
97+
// Wait for server to receive the "none" auth request
98+
// FakeSshServer doesn't have a way to await auth request easily, but we can just wait a bit or use yield
99+
yield()
100+
101+
val bannerText = "Welcome to the test server! Visit https://example.com/auth"
102+
server.sendUserauthBanner(bannerText)
103+
104+
// Now fail the auth so authenticate() returns
105+
server.sendUserauthFailure(setOf("password"), false)
106+
107+
authJob.join()
108+
109+
assertEquals(bannerText, bannerReceived.await())
110+
} finally {
111+
connection.close()
112+
}
113+
}
114+
}

0 commit comments

Comments
 (0)