Skip to content

Commit 2fff664

Browse files
nindanaotoclaude
authored andcommitted
Fix host key rotation crash with RSA algorithm name mismatch
Fixes connectbot/connectbot#2023. When the server sends hostkeys-00@openssh.com after auth, processHostkeysAdvertisement compared stored algorithm names (e.g. "rsa-sha2-512") against key blob identifiers ("ssh-rsa"). Since these are the same RSA key with different signature algorithms, the mismatch caused removeServerHostKey to be called with a null hostKey, crashing Kotlin callers. Changes: 1. Add normalizeKeyAlgorithm() to treat rsa-sha2-256/512 as ssh-rsa for key identity comparison. This prevents the false mismatch. 2. Catch Exception (not just IOException) around processHostkeysAdvertisement so RuntimeExceptions from verifier callbacks don't kill the receiver thread. 3. Move hostkeys-prove check before globalSuccessCounter increment in msgGlobalSuccess to prevent counter interference with other global requests. 4. Send REQUEST_SUCCESS (not FAILURE) for hostkeys requests we handle. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 17402aa commit 2fff664

2 files changed

Lines changed: 137 additions & 67 deletions

File tree

src/main/java/com/trilead/ssh2/channel/ChannelManager.java

Lines changed: 55 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -1676,24 +1676,12 @@ public void msgChannelOpenFailure(byte[] msg, int msglen) throws IOException
16761676

16771677
public void msgGlobalRequest(byte[] msg, int msglen) throws IOException
16781678
{
1679-
/* Currently we do not support any kind of global request */
1680-
16811679
TypesReader tr = new TypesReader(msg, 0, msglen);
16821680

16831681
tr.readByte(); // skip packet type
16841682
String requestName = tr.readString();
16851683
boolean wantReply = tr.readBoolean();
16861684

1687-
if (wantReply)
1688-
{
1689-
byte[] reply_failure = new byte[1];
1690-
reply_failure[0] = Packets.SSH_MSG_REQUEST_FAILURE;
1691-
1692-
tm.sendAsynchronousMessage(reply_failure);
1693-
}
1694-
1695-
/* We do not clean up the requestName String - that is OK for debug */
1696-
16971685
if (log.isEnabled())
16981686
log.log(80, "Got SSH_MSG_GLOBAL_REQUEST (" + requestName + ")");
16991687

@@ -1702,20 +1690,33 @@ public void msgGlobalRequest(byte[] msg, int msglen) throws IOException
17021690
try {
17031691
PacketGlobalHostkeys hostkeys = new PacketGlobalHostkeys(msg, 0, msglen);
17041692
processHostkeysAdvertisement(hostkeys, requestName);
1705-
} catch (IOException e) {
1693+
} catch (Exception e) {
17061694
if (log.isEnabled())
1707-
log.log(20, "Failed to parse hostkeys advertisement: " + e.getMessage());
1695+
log.log(20, "Failed to process hostkeys advertisement: " + e.getMessage());
17081696
}
1697+
// hostkeys-00@openssh.com typically has wantReply=false, but if the
1698+
// server does request a reply, acknowledge it since we processed it.
1699+
if (wantReply)
1700+
{
1701+
byte[] reply_success = new byte[1];
1702+
reply_success[0] = Packets.SSH_MSG_REQUEST_SUCCESS;
1703+
tm.sendAsynchronousMessage(reply_success);
1704+
}
1705+
return;
17091706
}
1710-
}
17111707

1712-
public void msgGlobalSuccess(byte[] msg, int msglen) throws IOException {
1713-
synchronized (channels)
1708+
if (wantReply)
17141709
{
1715-
globalSuccessCounter++;
1716-
channels.notifyAll();
1710+
byte[] reply_failure = new byte[1];
1711+
reply_failure[0] = Packets.SSH_MSG_REQUEST_FAILURE;
1712+
1713+
tm.sendAsynchronousMessage(reply_failure);
17171714
}
1715+
}
17181716

1717+
public void msgGlobalSuccess(byte[] msg, int msglen) throws IOException {
1718+
// Check for pending hostkeys-prove BEFORE incrementing the global counter,
1719+
// so the hostkeys-prove response doesn't interfere with other global request tracking.
17191720
synchronized (hostkeysProveLock) {
17201721
if (pendingHostkeysProve != null && !pendingHostkeysProve.completed) {
17211722
try {
@@ -1739,6 +1740,12 @@ public void msgGlobalSuccess(byte[] msg, int msglen) throws IOException {
17391740
}
17401741
}
17411742

1743+
synchronized (channels)
1744+
{
1745+
globalSuccessCounter++;
1746+
channels.notifyAll();
1747+
}
1748+
17421749
if (log.isEnabled())
17431750
log.log(80, "Got SSH_MSG_REQUEST_SUCCESS");
17441751
}
@@ -1882,19 +1889,28 @@ private void processHostkeysAdvertisement(PacketGlobalHostkeys hostkeys, String
18821889
}
18831890

18841891
List<String> knownAlgos = extVerifier.getKnownKeyAlgorithmsForHost(hostname, port);
1885-
Set<String> knownAlgoSet = (knownAlgos != null) ? new HashSet<>(knownAlgos) : new HashSet<>();
1892+
1893+
// Normalize algorithm names so RSA signature variants (rsa-sha2-256,
1894+
// rsa-sha2-512) are treated as the same key type as ssh-rsa.
1895+
Set<String> normalizedKnownAlgoSet = new HashSet<>();
1896+
if (knownAlgos != null) {
1897+
for (String algo : knownAlgos) {
1898+
normalizedKnownAlgoSet.add(normalizeKeyAlgorithm(algo));
1899+
}
1900+
}
18861901

18871902
List<byte[]> newKeys = new ArrayList<>();
1888-
Set<String> advertisedAlgoSet = new HashSet<>();
1903+
Set<String> normalizedAdvertisedAlgoSet = new HashSet<>();
18891904

18901905
for (byte[] keyBlob : advertisedKeys) {
18911906
String keyAlgo = extractKeyAlgorithm(keyBlob);
18921907
if (keyAlgo == null)
18931908
continue;
18941909

1895-
advertisedAlgoSet.add(keyAlgo);
1910+
String normalizedAlgo = normalizeKeyAlgorithm(keyAlgo);
1911+
normalizedAdvertisedAlgoSet.add(normalizedAlgo);
18961912

1897-
if (!knownAlgoSet.contains(keyAlgo)) {
1913+
if (!normalizedKnownAlgoSet.contains(normalizedAlgo)) {
18981914
newKeys.add(keyBlob);
18991915
}
19001916
}
@@ -1903,7 +1919,7 @@ private void processHostkeysAdvertisement(PacketGlobalHostkeys hostkeys, String
19031919
for (String knownAlgo : knownAlgos) {
19041920
if (knownAlgo == null)
19051921
continue;
1906-
if (!advertisedAlgoSet.contains(knownAlgo)) {
1922+
if (!normalizedAdvertisedAlgoSet.contains(normalizeKeyAlgorithm(knownAlgo))) {
19071923
extVerifier.removeServerHostKey(hostname, port, knownAlgo, null);
19081924
if (log.isEnabled())
19091925
log.log(50, "Removed hostkey algorithm no longer advertised: " + knownAlgo);
@@ -2050,6 +2066,21 @@ private String extractKeyAlgorithm(byte[] keyBlob) throws IOException
20502066
return tr.readString();
20512067
}
20522068

2069+
/**
2070+
* Normalizes RSA algorithm variants to a canonical form for comparison.
2071+
* In SSH, rsa-sha2-256 and rsa-sha2-512 use the same RSA key as ssh-rsa
2072+
* (the difference is only the signature hash algorithm). Key blobs always
2073+
* identify as ssh-rsa regardless of which signature algorithm was negotiated.
2074+
*/
2075+
static String normalizeKeyAlgorithm(String algorithm)
2076+
{
2077+
if (RSASHA256Verify.ID_RSA_SHA_2_256.equals(algorithm) ||
2078+
RSASHA512Verify.ID_RSA_SHA_2_512.equals(algorithm)) {
2079+
return RSASHA1Verify.ID_SSH_RSA;
2080+
}
2081+
return algorithm;
2082+
}
2083+
20532084
private SSHSignature getSignatureVerifier(String algorithm)
20542085
{
20552086
if (RSASHA1Verify.ID_SSH_RSA.equals(algorithm))

src/test/java/com/trilead/ssh2/channel/ChannelManagerTest.java

Lines changed: 82 additions & 43 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,12 @@
11
package com.trilead.ssh2.channel;
22

33
import com.trilead.ssh2.ChannelCondition;
4-
import com.trilead.ssh2.ConnectionInfo;
54
import com.trilead.ssh2.ExtendedServerHostKeyVerifier;
65
import com.trilead.ssh2.packets.PacketGlobalHostkeys;
76
import com.trilead.ssh2.packets.Packets;
87
import com.trilead.ssh2.packets.TypesWriter;
98
import com.trilead.ssh2.signature.RSASHA1Verify;
9+
import com.trilead.ssh2.signature.RSASHA256Verify;
1010
import com.trilead.ssh2.signature.RSASHA512Verify;
1111
import com.trilead.ssh2.transport.ITransportConnection;
1212
import org.junit.jupiter.api.BeforeEach;
@@ -19,7 +19,6 @@
1919
import java.io.IOException;
2020
import java.util.Arrays;
2121
import java.util.Collections;
22-
import java.util.List;
2322

2423
import static org.junit.jupiter.api.Assertions.assertEquals;
2524
import static org.junit.jupiter.api.Assertions.assertThrows;
@@ -820,81 +819,68 @@ private byte[] buildHostkeysGlobalRequest(String requestName, boolean wantReply,
820819
}
821820

822821
/**
823-
* Simulates the scenario from GitHub issue connectbot/connectbot#2023:
822+
* Regression test for GitHub issue connectbot/connectbot#2023:
824823
*
825-
* 1. ConnectBot stores the host key algorithm as "rsa-sha2-512" (negotiated algo).
826-
* 2. Server sends hostkeys-00@openssh.com advertising its RSA key blob
827-
* which contains "ssh-rsa" as the key format identifier.
828-
* 3. processHostkeysAdvertisement sees "rsa-sha2-512" NOT in the advertised set
829-
* {"ssh-rsa"} and calls removeServerHostKey(hostname, port, "rsa-sha2-512", null).
830-
* 4. A Kotlin implementation (like ConnectBot's) declares hostKey as non-nullable
831-
* ByteArray, so passing null throws NullPointerException, crashing the app.
824+
* Before the fix, when the stored algorithm was "rsa-sha2-512" and the
825+
* key blob contained "ssh-rsa", processHostkeysAdvertisement would call
826+
* removeServerHostKey with null hostKey (crashing Kotlin callers).
832827
*
833-
* This test verifies the bug: removeServerHostKey is called with null hostKey.
828+
* After the fix, RSA algorithm variants are normalized so "rsa-sha2-512"
829+
* and "ssh-rsa" are recognized as the same key type. removeServerHostKey
830+
* should NOT be called, since the key is still present.
834831
*/
835832
@Test
836-
public void testHostkeysAdvertisement_rsaAlgoMismatch_callsRemoveWithNull() throws Exception {
837-
// Set up an ExtendedServerHostKeyVerifier that reports "rsa-sha2-512" as known
833+
public void testHostkeysAdvertisement_rsaAlgoMismatch_noRemovalAfterFix() throws Exception {
838834
ExtendedServerHostKeyVerifier mockVerifier = mock(ExtendedServerHostKeyVerifier.class);
839835
when(mockVerifier.getKnownKeyAlgorithmsForHost(anyString(), anyInt()))
840836
.thenReturn(Collections.singletonList(RSASHA512Verify.ID_RSA_SHA_2_512));
841837

842-
// Mock getConnectionInfo so requestHostkeysProve doesn't NPE on ConnectionInfo
843-
ConnectionInfo connInfo = new ConnectionInfo();
844-
connInfo.serverHostKeyAlgorithm = RSASHA512Verify.ID_RSA_SHA_2_512;
845-
when(mockTransportConnection.getConnectionInfo(anyInt())).thenReturn(connInfo);
846-
847838
when(mockTransportConnection.getServerHostKeyVerifier()).thenReturn(mockVerifier);
848839
when(mockTransportConnection.getHostname()).thenReturn("esxi.example.com");
849840
when(mockTransportConnection.getPort()).thenReturn(22);
850841

851-
// Server advertises an RSA key blob (algorithm in blob = "ssh-rsa")
852842
byte[] rsaKeyBlob = buildKeyBlob(RSASHA1Verify.ID_SSH_RSA);
853843
byte[] msg = buildHostkeysGlobalRequest(
854844
"hostkeys-00@openssh.com", false, rsaKeyBlob);
855845

856846
channelManager.handleMessage(msg, msg.length);
857847

858-
// BUG: removeServerHostKey is called with null hostKey because
859-
// "rsa-sha2-512" (known) is not in {"ssh-rsa"} (advertised)
860-
ArgumentCaptor<byte[]> hostKeyCaptor = ArgumentCaptor.forClass(byte[].class);
861-
verify(mockVerifier).removeServerHostKey(
862-
anyString(), anyInt(), anyString(), hostKeyCaptor.capture());
863-
864-
// This proves the bug: the hostKey argument is null
865-
assertNull(hostKeyCaptor.getValue(),
866-
"removeServerHostKey should NOT be called with null hostKey, " +
867-
"but currently it is due to RSA algorithm name mismatch");
848+
// After fix: rsa-sha2-512 is normalized to ssh-rsa, so the algorithm
849+
// is recognized as still advertised. removeServerHostKey must NOT be called.
850+
verify(mockVerifier, never()).removeServerHostKey(
851+
anyString(), anyInt(), anyString(), any());
868852
}
869853

870854
/**
871-
* Simulates the crash: if removeServerHostKey throws when called with null
872-
* (as Kotlin's non-nullable ByteArray check does), the exception propagates
873-
* uncaught through handleMessage, killing the SSH receiver thread and
874-
* crashing the app.
855+
* Regression test: even if removeServerHostKey throws (e.g., Kotlin's
856+
* non-nullable parameter check), it must not propagate out of handleMessage
857+
* and kill the SSH receiver thread.
875858
*/
876859
@Test
877-
public void testHostkeysAdvertisement_rsaAlgoMismatch_crashesReceiverThread() throws Exception {
878-
// Set up an ExtendedServerHostKeyVerifier that throws NPE on null hostKey
879-
// (simulating Kotlin's non-nullable parameter check)
860+
public void testHostkeysAdvertisement_removeThrows_doesNotCrashReceiverThread() throws Exception {
861+
// Use a non-RSA algorithm so normalization doesn't prevent the removal call.
862+
// Pretend the client knows "ssh-dss" but server no longer advertises it.
880863
ExtendedServerHostKeyVerifier mockVerifier = mock(ExtendedServerHostKeyVerifier.class);
881864
when(mockVerifier.getKnownKeyAlgorithmsForHost(anyString(), anyInt()))
882-
.thenReturn(Collections.singletonList(RSASHA512Verify.ID_RSA_SHA_2_512));
865+
.thenReturn(Collections.singletonList("ssh-dss"));
883866
doThrow(new NullPointerException("Parameter specified as non-null is null: parameter hostKey"))
884867
.when(mockVerifier).removeServerHostKey(anyString(), anyInt(), anyString(), nullable(byte[].class));
885868

886869
when(mockTransportConnection.getServerHostKeyVerifier()).thenReturn(mockVerifier);
887870
when(mockTransportConnection.getHostname()).thenReturn("esxi.example.com");
888871
when(mockTransportConnection.getPort()).thenReturn(22);
889872

890-
byte[] rsaKeyBlob = buildKeyBlob(RSASHA1Verify.ID_SSH_RSA);
873+
// Server advertises only an ed25519 key (no DSS)
874+
byte[] ed25519KeyBlob = buildKeyBlob("ssh-ed25519");
891875
byte[] msg = buildHostkeysGlobalRequest(
892-
"hostkeys-00@openssh.com", false, rsaKeyBlob);
876+
"hostkeys-00@openssh.com", false, ed25519KeyBlob);
877+
878+
// Even if removeServerHostKey throws, handleMessage must NOT propagate it
879+
channelManager.handleMessage(msg, msg.length);
893880

894-
// The NPE propagates out of handleMessage — this kills the receiver thread
895-
// and crashes the Android app
896-
assertThrows(NullPointerException.class, () ->
897-
channelManager.handleMessage(msg, msg.length));
881+
// The call did happen (and threw), but the exception was caught
882+
verify(mockVerifier).removeServerHostKey(
883+
anyString(), anyInt(), anyString(), nullable(byte[].class));
898884
}
899885

900886
/**
@@ -922,4 +908,57 @@ public void testHostkeysAdvertisement_matchingAlgo_noRemoval() throws Exception
922908
verify(mockVerifier, never()).removeServerHostKey(
923909
anyString(), anyInt(), anyString(), any());
924910
}
911+
912+
// ---- normalizeKeyAlgorithm tests ----
913+
914+
@Test
915+
public void testNormalizeKeyAlgorithm_rsaSha2_512() {
916+
assertEquals(RSASHA1Verify.ID_SSH_RSA,
917+
ChannelManager.normalizeKeyAlgorithm(RSASHA512Verify.ID_RSA_SHA_2_512));
918+
}
919+
920+
@Test
921+
public void testNormalizeKeyAlgorithm_rsaSha2_256() {
922+
assertEquals(RSASHA1Verify.ID_SSH_RSA,
923+
ChannelManager.normalizeKeyAlgorithm(RSASHA256Verify.ID_RSA_SHA_2_256));
924+
}
925+
926+
@Test
927+
public void testNormalizeKeyAlgorithm_sshRsa_unchanged() {
928+
assertEquals(RSASHA1Verify.ID_SSH_RSA,
929+
ChannelManager.normalizeKeyAlgorithm(RSASHA1Verify.ID_SSH_RSA));
930+
}
931+
932+
@Test
933+
public void testNormalizeKeyAlgorithm_nonRsa_unchanged() {
934+
assertEquals("ssh-ed25519",
935+
ChannelManager.normalizeKeyAlgorithm("ssh-ed25519"));
936+
assertEquals("ssh-dss",
937+
ChannelManager.normalizeKeyAlgorithm("ssh-dss"));
938+
assertEquals("ecdsa-sha2-nistp256",
939+
ChannelManager.normalizeKeyAlgorithm("ecdsa-sha2-nistp256"));
940+
}
941+
942+
// ---- msgGlobalRequest hostkeys reply tests ----
943+
944+
@Test
945+
public void testMsgGlobalRequest_hostkeys_withReply_sendsSuccess() throws Exception {
946+
ExtendedServerHostKeyVerifier mockVerifier = mock(ExtendedServerHostKeyVerifier.class);
947+
when(mockVerifier.getKnownKeyAlgorithmsForHost(anyString(), anyInt())).thenReturn(null);
948+
when(mockTransportConnection.getServerHostKeyVerifier()).thenReturn(mockVerifier);
949+
when(mockTransportConnection.getHostname()).thenReturn("example.com");
950+
when(mockTransportConnection.getPort()).thenReturn(22);
951+
952+
byte[] rsaKeyBlob = buildKeyBlob(RSASHA1Verify.ID_SSH_RSA);
953+
byte[] msg = buildHostkeysGlobalRequest(
954+
"hostkeys-00@openssh.com", true, rsaKeyBlob);
955+
956+
channelManager.handleMessage(msg, msg.length);
957+
958+
// Should send REQUEST_SUCCESS (not FAILURE) for handled hostkeys request
959+
ArgumentCaptor<byte[]> replyCaptor = ArgumentCaptor.forClass(byte[].class);
960+
verify(mockTransportConnection).sendAsynchronousMessage(replyCaptor.capture());
961+
assertEquals(Packets.SSH_MSG_REQUEST_SUCCESS, replyCaptor.getValue()[0],
962+
"Hostkeys global request should get SUCCESS reply, not FAILURE");
963+
}
925964
}

0 commit comments

Comments
 (0)