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
20 changes: 15 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,16 @@ The library supports a wide range of modern SSH algorithms, including:

For a complete list of supported algorithms and their respective RFCs, see [docs/ALGORITHMS.md](docs/ALGORITHMS.md).

The defaults intentionally exclude SHA-1 key exchange and MACs, CBC/3DES
ciphers, and `ssh-rsa` host-key signatures. These legacy algorithms remain
available only through the explicit `kexAlgorithms`, `hostKeyAlgorithms`,
`encryptionAlgorithms`, and `macAlgorithms` settings in `SshClientConfig`.
RSA user authentication normally requires the server to advertise
`rsa-sha2-256` or `rsa-sha2-512` through `server-sig-algs`. Explicitly including
`ssh-rsa` in the configured host-key algorithm wishlist also permits the legacy
RSA/SHA-1 signature when advertised, or as the base-key algorithm when that
extension is absent.

## Quick Start

### Build
Expand Down Expand Up @@ -125,23 +135,23 @@ Enable SSH agent forwarding to allow remote servers to use your keys:
```kotlin
// Implement an agent provider
class MyAgentProvider : AgentProvider {
override suspend fun getIdentities(): List<AgentIdentity> {
override suspend fun getIdentities(): AgentResult<List<AgentIdentity>> {
val keyBlob = loadPublicKeyBlob()
return listOf(AgentIdentity(keyBlob, "my-key"))
return AgentResult.Success(listOf(AgentIdentity(keyBlob, "my-key")))
}

override suspend fun signData(context: AgentSigningContext): ByteArray? {
override suspend fun signData(context: AgentSigningContext): AgentResult<ByteArray?> {
// Show approval UI to user with session context
val approved = showSigningPrompt(
"Remote server ${context.serverHostKey.toHex()} wants to use your key",
"Session bound: ${context.isBound}"
)

return if (approved) {
return AgentResult.Success(if (approved) {
signWithPrivateKey(context.publicKeyBlob, context.dataToSign)
} else {
null // Deny the request
}
})
}
}

Expand Down
26 changes: 15 additions & 11 deletions docs/ALGORITHMS.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,10 @@

This document lists the cryptographic algorithms supported by the ConnectBot SSH library.

Algorithms labeled **legacy opt-in** are implemented for compatibility but are
not offered by the default `SshClientConfig`. Applications must add them
explicitly to the corresponding algorithm string.

## Authentication
- `keyboard-interactive`
- `password`
Expand All @@ -15,7 +19,7 @@ This document lists the cryptographic algorithms supported by the ConnectBot SSH
- `ecdsa-sha2-nistp521` ([RFC 5656](https://tools.ietf.org/html/rfc5656#section-3))
- `rsa-sha2-512` ([RFC 8332](https://tools.ietf.org/html/rfc8332#section-3))
- `rsa-sha2-256` ([RFC 8332](https://tools.ietf.org/html/rfc8332#section-3))
- `ssh-rsa` ([RFC 4253](https://tools.ietf.org/html/rfc4253#section-8.1))
- `ssh-rsa` ([RFC 4253](https://tools.ietf.org/html/rfc4253#section-8.1)) — **legacy opt-in**

## Key Exchange
- `mlkem768x25519-sha256` ([draft-ietf-sshm-mlkem-hybrid-kex](https://datatracker.ietf.org/doc/draft-ietf-sshm-mlkem-hybrid-kex/)) (depends on JEP-496 support)
Expand All @@ -27,24 +31,24 @@ This document lists the cryptographic algorithms supported by the ConnectBot SSH
- `diffie-hellman-group16-sha512` ([RFC 8268](https://tools.ietf.org/html/rfc8268))
- `diffie-hellman-group14-sha256` ([RFC 8268](https://tools.ietf.org/html/rfc8268))
- `diffie-hellman-group-exchange-sha256` ([RFC 4419](https://tools.ietf.org/html/rfc4419))
- `diffie-hellman-group14-sha1` ([RFC 4253](https://tools.ietf.org/html/rfc4253#section-8.1))
- `diffie-hellman-group-exchange-sha1` ([RFC 4419](https://tools.ietf.org/html/rfc4419))
- `diffie-hellman-group1-sha1` ([RFC 4253](https://tools.ietf.org/html/rfc4253#section-8.1))
- `diffie-hellman-group14-sha1` ([RFC 4253](https://tools.ietf.org/html/rfc4253#section-8.1)) — **legacy opt-in**
- `diffie-hellman-group-exchange-sha1` ([RFC 4419](https://tools.ietf.org/html/rfc4419)) — **legacy opt-in**
- `diffie-hellman-group1-sha1` ([RFC 4253](https://tools.ietf.org/html/rfc4253#section-8.1)) — **legacy opt-in**

## Encryption
- `chacha20-poly1305@openssh.com` ([draft-ietf-sshm-chacha20-poly1305](https://datatracker.ietf.org/doc/html/draft-ietf-sshm-chacha20-poly1305))
- `aes256-gcm@openssh.com` ([draft-miller-sshm-aes-gcm](https://datatracker.ietf.org/doc/html/draft-miller-sshm-aes-gcm))
- `aes128-gcm@openssh.com` ([draft-miller-sshm-aes-gcm](https://datatracker.ietf.org/doc/html/draft-miller-sshm-aes-gcm))
- `aes256-ctr` ([RFC 4344](https://tools.ietf.org/html/rfc4344#section-4))
- `aes128-ctr` ([RFC 4344](https://tools.ietf.org/html/rfc4344#section-4))
- `aes256-cbc` ([RFC 4253](https://tools.ietf.org/html/rfc4253#section-6.3))
- `aes128-cbc` ([RFC 4253](https://tools.ietf.org/html/rfc4253#section-6.3))
- `3des-cbc` ([RFC 4253](https://datatracker.ietf.org/doc/html/rfc4253#section-6.3))
- `aes256-cbc` ([RFC 4253](https://tools.ietf.org/html/rfc4253#section-6.3)) — **legacy opt-in**
- `aes128-cbc` ([RFC 4253](https://tools.ietf.org/html/rfc4253#section-6.3)) — **legacy opt-in**
- `3des-cbc` ([RFC 4253](https://datatracker.ietf.org/doc/html/rfc4253#section-6.3)) — **legacy opt-in**

## MACs
- `hmac-sha2-512-etm@openssh.com` ([OpenSSH PROTOCOL](https://github.com/openssh/openssh-portable/blob/60b909fb110f77c1ffd15cceb5d09b8e3f79b27e/PROTOCOL#L50))
- `hmac-sha2-256-etm@openssh.com` ([OpenSSH PROTOCOL](https://github.com/openssh/openssh-portable/blob/60b909fb110f77c1ffd15cceb5d09b8e3f79b27e/PROTOCOL#L50))
- `hmac-sha1-etm@openssh.com` ([OpenSSH PROTOCOL](https://github.com/openssh/openssh-portable/blob/60b909fb110f77c1ffd15cceb5d09b8e3f79b27e/PROTOCOL#L50))
- `hmac-sha2-512` ([RFC 4868](https://tools.ietf.org/html/rfc4868))
- `hmac-sha2-256` ([RFC 4868](https://tools.ietf.org/html/rfc4868))
- `hmac-sha1` ([RFC 4253](https://tools.ietf.org/html/rfc4253))
- `hmac-sha1-etm@openssh.com` ([OpenSSH PROTOCOL](https://github.com/openssh/openssh-portable/blob/60b909fb110f77c1ffd15cceb5d09b8e3f79b27e/PROTOCOL#L50)) — **legacy opt-in**
- `hmac-sha2-512` ([RFC 4868](https://tools.ietf.org/html/rfc4868)) — **legacy opt-in**
- `hmac-sha2-256` ([RFC 4868](https://tools.ietf.org/html/rfc4868)) — **legacy opt-in**
- `hmac-sha1` ([RFC 4253](https://tools.ietf.org/html/rfc4253)) — **legacy opt-in**
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@ seq:
- id: session_identifier
type: byte_string
doc: SSH session identifier (H from key exchange)
valid:
expr: _.data.size <= 128
- id: signature
type: byte_string
doc: Signature of (hostkey || session_identifier) by host key
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,9 +22,13 @@ seq:
- id: service_name
type: ascii_string
doc: Service name
valid:
expr: _.value == "ssh-connection"
- id: method_name
type: ascii_string
doc: Authentication method name
valid:
expr: _.value == "publickey" or _.value == "publickey-hostbound-v00@openssh.com"
- id: has_signature
contents: [1]
doc: TRUE (1)
Expand Down
32 changes: 30 additions & 2 deletions sshlib/api.txt
Original file line number Diff line number Diff line change
Expand Up @@ -33,8 +33,36 @@ package org.connectbot.sshlib {
}

public interface AgentProvider {
method public suspend java.lang.Object? getIdentities(kotlin.coroutines.Continuation<? super java.util.List<org.connectbot.sshlib.AgentIdentity>>);
method public suspend java.lang.Object? signData(org.connectbot.sshlib.AgentSigningContext context, kotlin.coroutines.Continuation<? super byte[]?>);
method public suspend java.lang.Object? getIdentities(kotlin.coroutines.Continuation<? super org.connectbot.sshlib.AgentResult<? extends java.util.List<org.connectbot.sshlib.AgentIdentity>>>);
method public suspend java.lang.Object? signData(org.connectbot.sshlib.AgentSigningContext context, kotlin.coroutines.Continuation<? super org.connectbot.sshlib.AgentResult<byte[]?>>);
}

public sealed exhaustive interface AgentResult<T> {
}

public static final class AgentResult.Failure implements org.connectbot.sshlib.AgentResult {
ctor public AgentResult.Failure(java.lang.String message, optional java.lang.Throwable? cause);
method public java.lang.String component1();
method public java.lang.Throwable? component2();
method public org.connectbot.sshlib.AgentResult.Failure copy(optional java.lang.String message, optional java.lang.Throwable? cause);
method public boolean equals(java.lang.Object? other);
method @InaccessibleFromKotlin public java.lang.Throwable? getCause();
method @InaccessibleFromKotlin public java.lang.String getMessage();
method public int hashCode();
method public java.lang.String toString();
property public Throwable? cause;
property public String message;
}

public static final class AgentResult.Success<T> implements org.connectbot.sshlib.AgentResult<T> {
ctor public AgentResult.Success(T value);
method public T component1();
method public org.connectbot.sshlib.AgentResult.Success<T> copy(optional T value);
method public boolean equals(java.lang.Object? other);
method @InaccessibleFromKotlin public T getValue();
method public int hashCode();
method public java.lang.String toString();
property public T value;
}

public final class AgentSignatureFlags {
Expand Down
18 changes: 11 additions & 7 deletions sshlib/src/main/kotlin/org/connectbot/sshlib/AgentProvider.kt
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
/*
* ConnectBot SSH Library
* Copyright 2025 Kenny Root
* 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 @@ -69,28 +69,32 @@ data class DestinationConstraint(
*
* Implement this interface to handle agent requests from remote servers
* when agent forwarding is enabled. The library will call these methods
* when a remote server requests identities or signatures.
* when a remote server requests identities or signatures. Implementations
* should return [AgentResult.Failure] instead of throwing when a backend
* operation fails.
*/
interface AgentProvider {
/**
* Return the list of identities available for forwarding.
*
* Called when a remote server requests the list of available keys.
* Return [AgentResult.Failure] if the identities cannot be loaded.
*/
suspend fun getIdentities(): List<AgentIdentity>
suspend fun getIdentities(): AgentResult<List<AgentIdentity>>

/**
* Sign data with the specified key.
*
* Called when a remote server requests a signature. The provider should:
* 1. Verify the request is legitimate (check context)
* 2. Optionally prompt the user for approval
* 3. Return the signature, or null to deny the request
* 3. Return the signature, or a successful null value to deny the request
*
* @param context Rich context about the signing request
* @return SSH-encoded signature blob, or null to refuse
* @return Successful SSH-encoded signature blob, successful null to refuse,
* or [AgentResult.Failure] if the provider could not process the request
*/
suspend fun signData(context: AgentSigningContext): ByteArray?
suspend fun signData(context: AgentSigningContext): AgentResult<ByteArray?>
}

/**
Expand Down Expand Up @@ -145,7 +149,7 @@ data class AgentSigningContext(
/** Server's host key from key exchange */
val serverHostKey: ByteArray,

/** Whether session-bind extension was used */
/** Whether a trusted session binding is active */
val isBound: Boolean,
) {
override fun equals(other: Any?): Boolean {
Expand Down
35 changes: 35 additions & 0 deletions sshlib/src/main/kotlin/org/connectbot/sshlib/AgentResult.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
/*
* 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

/**
* Result of an [AgentProvider] callback.
*
* Provider failures are values rather than thrown exceptions so an agent
* backend failure cannot terminate the SSH connection's packet loop.
*/
sealed interface AgentResult<out T> {
/** The provider completed successfully with [value]. */
data class Success<T>(val value: T) : AgentResult<T>

/** The provider could not complete the request. */
data class Failure(
val message: String,
val cause: Throwable? = null,
) : AgentResult<Nothing>
}
5 changes: 3 additions & 2 deletions sshlib/src/main/kotlin/org/connectbot/sshlib/SshKeys.kt
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
/*
* ConnectBot SSH Library
* Copyright 2025 Kenny Root
* 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 @@ -57,7 +57,8 @@ object SshKeys {
* - Ed25519 keys are encoded as PKCS#8 (`BEGIN PRIVATE KEY`)
*
* @param keyPair JCA key pair to encode
* @param password Optional passphrase to encrypt the key (AES-256-CBC)
* @param password Optional passphrase to encrypt the key. RSA and EC use legacy
* PEM AES-256-CBC encryption; Ed25519 uses PBES2 encrypted PKCS#8.
* @return PEM-encoded private key string
* @throws SshException if the key type is unsupported
*/
Expand Down
6 changes: 5 additions & 1 deletion sshlib/src/main/kotlin/org/connectbot/sshlib/SshSession.kt
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
/*
* ConnectBot SSH Library
* Copyright 2025 Kenny Root
* 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 @@ -78,6 +78,10 @@ interface SshSession : AutoCloseable {

suspend fun read(): ByteArray?

/**
* Read non-stderr extended data. RFC 4254 data type 1 is exposed exclusively
* through [stderr] so the same remote bytes are not buffered twice.
*/
suspend fun readExtended(): Pair<Int, ByteArray>?

suspend fun sendEof()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,12 +17,17 @@

package org.connectbot.sshlib.client

import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Job
import kotlinx.coroutines.cancelAndJoin
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.launch
import org.slf4j.LoggerFactory

internal class AgentChannel(
private val handler: AgentProtocolHandler,
private val connection: SshConnection,
scope: CoroutineScope,
private val localChannelNumber: Int,
private var remoteChannelNumber: Int,
private val maxPacketSize: Int,
Expand All @@ -38,6 +43,16 @@ internal class AgentChannel(

private val window = LocalChannelWindow(initialWindowSize, remoteInitial = remoteWindowSizeInitial)
private val windowAvailable = Channel<Unit>(Channel.CONFLATED)
private val requests = Channel<ByteArray>(Channel.UNLIMITED)
private val requestWorker: Job = scope.launch {
for (data in requests) {
val response = handler.handleRequest(data)
connection.sendWindowAdjust(remoteChannelNumber, window.releaseLocal(data.size))

logger.debug("Sending agent response (${response.size} bytes)")
sendData(response)
}
}

val isOpen: Boolean get() = _isOpen

Expand All @@ -46,17 +61,13 @@ internal class AgentChannel(
logger.warn("Received data on closed agent channel")
return
}
val adjust = window.consumeLocal(data.size)
window.consumeLocal(data.size)

logger.debug("Agent channel received ${data.size} bytes")
if (adjust > 0) {
connection.sendWindowAdjust(remoteChannelNumber, adjust)
logger.debug("Queueing ${data.size} bytes received on agent channel")
if (requests.trySend(data).isFailure) {
window.releaseLocal(data.size)
logger.warn("Discarding data received while agent channel is closing")
}

val response = handler.handleRequest(data)

logger.debug("Sending agent response (${response.size} bytes)")
sendData(response)
}

fun onWindowAdjust(bytesToAdd: Long) {
Expand All @@ -82,6 +93,8 @@ internal class AgentChannel(
}
}
_isOpen = false
requests.close()
requestWorker.cancelAndJoin()
windowAvailable.close()
}

Expand Down
Loading
Loading