|
1 | 1 | package com.trilead.ssh2.channel; |
2 | 2 |
|
3 | 3 | import com.trilead.ssh2.ChannelCondition; |
4 | | -import com.trilead.ssh2.ConnectionInfo; |
5 | 4 | import com.trilead.ssh2.ExtendedServerHostKeyVerifier; |
6 | 5 | import com.trilead.ssh2.packets.PacketGlobalHostkeys; |
7 | 6 | import com.trilead.ssh2.packets.Packets; |
8 | 7 | import com.trilead.ssh2.packets.TypesWriter; |
9 | 8 | import com.trilead.ssh2.signature.RSASHA1Verify; |
| 9 | +import com.trilead.ssh2.signature.RSASHA256Verify; |
10 | 10 | import com.trilead.ssh2.signature.RSASHA512Verify; |
11 | 11 | import com.trilead.ssh2.transport.ITransportConnection; |
12 | 12 | import org.junit.jupiter.api.BeforeEach; |
|
19 | 19 | import java.io.IOException; |
20 | 20 | import java.util.Arrays; |
21 | 21 | import java.util.Collections; |
22 | | -import java.util.List; |
23 | 22 |
|
24 | 23 | import static org.junit.jupiter.api.Assertions.assertEquals; |
25 | 24 | import static org.junit.jupiter.api.Assertions.assertThrows; |
@@ -820,81 +819,68 @@ private byte[] buildHostkeysGlobalRequest(String requestName, boolean wantReply, |
820 | 819 | } |
821 | 820 |
|
822 | 821 | /** |
823 | | - * Simulates the scenario from GitHub issue connectbot/connectbot#2023: |
| 822 | + * Regression test for GitHub issue connectbot/connectbot#2023: |
824 | 823 | * |
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). |
832 | 827 | * |
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. |
834 | 831 | */ |
835 | 832 | @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 { |
838 | 834 | ExtendedServerHostKeyVerifier mockVerifier = mock(ExtendedServerHostKeyVerifier.class); |
839 | 835 | when(mockVerifier.getKnownKeyAlgorithmsForHost(anyString(), anyInt())) |
840 | 836 | .thenReturn(Collections.singletonList(RSASHA512Verify.ID_RSA_SHA_2_512)); |
841 | 837 |
|
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 | | - |
847 | 838 | when(mockTransportConnection.getServerHostKeyVerifier()).thenReturn(mockVerifier); |
848 | 839 | when(mockTransportConnection.getHostname()).thenReturn("esxi.example.com"); |
849 | 840 | when(mockTransportConnection.getPort()).thenReturn(22); |
850 | 841 |
|
851 | | - // Server advertises an RSA key blob (algorithm in blob = "ssh-rsa") |
852 | 842 | byte[] rsaKeyBlob = buildKeyBlob(RSASHA1Verify.ID_SSH_RSA); |
853 | 843 | byte[] msg = buildHostkeysGlobalRequest( |
854 | 844 | "hostkeys-00@openssh.com", false, rsaKeyBlob); |
855 | 845 |
|
856 | 846 | channelManager.handleMessage(msg, msg.length); |
857 | 847 |
|
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()); |
868 | 852 | } |
869 | 853 |
|
870 | 854 | /** |
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. |
875 | 858 | */ |
876 | 859 | @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. |
880 | 863 | ExtendedServerHostKeyVerifier mockVerifier = mock(ExtendedServerHostKeyVerifier.class); |
881 | 864 | when(mockVerifier.getKnownKeyAlgorithmsForHost(anyString(), anyInt())) |
882 | | - .thenReturn(Collections.singletonList(RSASHA512Verify.ID_RSA_SHA_2_512)); |
| 865 | + .thenReturn(Collections.singletonList("ssh-dss")); |
883 | 866 | doThrow(new NullPointerException("Parameter specified as non-null is null: parameter hostKey")) |
884 | 867 | .when(mockVerifier).removeServerHostKey(anyString(), anyInt(), anyString(), nullable(byte[].class)); |
885 | 868 |
|
886 | 869 | when(mockTransportConnection.getServerHostKeyVerifier()).thenReturn(mockVerifier); |
887 | 870 | when(mockTransportConnection.getHostname()).thenReturn("esxi.example.com"); |
888 | 871 | when(mockTransportConnection.getPort()).thenReturn(22); |
889 | 872 |
|
890 | | - byte[] rsaKeyBlob = buildKeyBlob(RSASHA1Verify.ID_SSH_RSA); |
| 873 | + // Server advertises only an ed25519 key (no DSS) |
| 874 | + byte[] ed25519KeyBlob = buildKeyBlob("ssh-ed25519"); |
891 | 875 | 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); |
893 | 880 |
|
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)); |
898 | 884 | } |
899 | 885 |
|
900 | 886 | /** |
@@ -922,4 +908,57 @@ public void testHostkeysAdvertisement_matchingAlgo_noRemoval() throws Exception |
922 | 908 | verify(mockVerifier, never()).removeServerHostKey( |
923 | 909 | anyString(), anyInt(), anyString(), any()); |
924 | 910 | } |
| 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 | + } |
925 | 964 | } |
0 commit comments