From be3ed2c21b9dee6e36f14fd4c2c91267a7d1e3fc Mon Sep 17 00:00:00 2001 From: Kenny Root Date: Thu, 8 Oct 2026 23:39:53 -0700 Subject: [PATCH] feat: add probePublicKey Allows callers to check if the server will accept a public key. --- .../java/com/trilead/ssh2/Connection.java | 20 ++ .../ssh2/auth/AuthenticationManager.java | 272 +++++++----------- .../PacketUserauthRequestPublicKey.java | 5 +- .../com/trilead/ssh2/packets/Packets.java | 1 + .../ssh2/DropbearCompatibilityTest.java | 15 + .../ssh2/OpenSSHCompatibilityTest.java | 53 ++++ .../auth/AuthenticationManagerProbeTest.java | 248 ++++++++++++++++ .../channel/AuthAgentForwardThreadTest.java | 91 ++++++ 8 files changed, 539 insertions(+), 166 deletions(-) create mode 100644 src/test/java/com/trilead/ssh2/auth/AuthenticationManagerProbeTest.java create mode 100644 src/test/java/com/trilead/ssh2/channel/AuthAgentForwardThreadTest.java diff --git a/src/main/java/com/trilead/ssh2/Connection.java b/src/main/java/com/trilead/ssh2/Connection.java index 65baf1a0..5ec73ab9 100644 --- a/src/main/java/com/trilead/ssh2/Connection.java +++ b/src/main/java/com/trilead/ssh2/Connection.java @@ -8,6 +8,7 @@ import java.net.InetSocketAddress; import java.net.SocketTimeoutException; import java.security.KeyPair; +import java.security.PublicKey; import java.security.SecureRandom; import java.util.ArrayList; import java.util.List; @@ -380,6 +381,25 @@ public synchronized boolean authenticateWithPublicKey(String user, KeyPair pair) return authenticated; } + + /** + * Offer a public key without signing. Acceptance only indicates that the + * server is willing to authenticate with this key; it does not authenticate + * the connection. Rejected offers may count toward the server's attempt limit. + * + * @param user username to authenticate + * @param key public key to offer + * @return true for a matching SSH_MSG_USERAUTH_PK_OK, false for rejection + * @throws IOException on transport or protocol error + */ + public synchronized boolean probePublicKey(String user, PublicKey key) throws IOException + { + if (key == null) + throw new IllegalArgumentException("Public key argument is null"); + checkRequirements(user); + return am.probePublicKey(user, key); + } + /** * A convenience wrapper function which reads in a private key (PEM format, * either DSA, EC, or RSA) and then calls diff --git a/src/main/java/com/trilead/ssh2/auth/AuthenticationManager.java b/src/main/java/com/trilead/ssh2/auth/AuthenticationManager.java index bdeaf4c7..1975d410 100644 --- a/src/main/java/com/trilead/ssh2/auth/AuthenticationManager.java +++ b/src/main/java/com/trilead/ssh2/auth/AuthenticationManager.java @@ -2,7 +2,6 @@ package com.trilead.ssh2.auth; import com.trilead.ssh2.crypto.PublicKeyUtils; -import com.trilead.ssh2.crypto.keys.Ed25519PrivateKey; import com.trilead.ssh2.signature.RSASHA256Verify; import com.trilead.ssh2.signature.RSASHA512Verify; import java.io.IOException; @@ -14,6 +13,7 @@ import java.security.interfaces.ECPublicKey; import java.security.interfaces.RSAPublicKey; import java.util.ArrayList; +import java.util.Arrays; import java.util.List; import java.util.Set; @@ -32,6 +32,7 @@ import com.trilead.ssh2.packets.PacketUserauthRequestPublicKey; import com.trilead.ssh2.packets.Packets; import com.trilead.ssh2.packets.TypesWriter; +import com.trilead.ssh2.packets.TypesReader; import com.trilead.ssh2.signature.DSASHA1Verify; import com.trilead.ssh2.signature.ECDSASHA2Verify; import com.trilead.ssh2.signature.Ed25519Verify; @@ -241,191 +242,134 @@ public boolean authenticatePublicKey(String user, KeyPair pair, SecureRandom rnd if (!methodPossible("publickey")) throw new IOException("Authentication method publickey not supported by the server at this stage."); - if (publicKey instanceof DSAPublicKey) - { - SSHSignature s = DSASHA1Verify.get(); - byte[] pk_enc = s.encodePublicKey(publicKey); - - byte[] msg = this.generatePublicKeyUserAuthenticationRequest(user, DSASHA1Verify.ID_SSH_DSS, pk_enc); - - byte[] ds_enc; - if (signatureProxy != null) - { - ds_enc = signatureProxy.sign(msg, SignatureProxy.SHA1); - } - else - { - ds_enc = s.generateSignature(msg, privateKey, rnd); - } - - PacketUserauthRequestPublicKey ua = new PacketUserauthRequestPublicKey("ssh-connection", user, - DSASHA1Verify.ID_SSH_DSS, pk_enc, ds_enc); - tm.sendMessage(ua.getPayload()); - } - else if (publicKey instanceof RSAPublicKey) - { - byte[] pk_enc = RSASHA1Verify.get().encodePublicKey(publicKey); - String pk_algorithm; - + PublicKeyOffer offer = publicKeyOffer(publicKey); + byte[] msg = generatePublicKeyUserAuthenticationRequest(user, offer.algorithm, offer.blob); + byte[] signature; + if (signatureProxy != null) + signature = signatureProxy.sign(msg, offer.hashAlgorithm); + else if (offer.verifier != null) + signature = offer.verifier.generateSignature(msg, privateKey, rnd); + else + throw new IOException("SK key authentication requires a SignatureProxy for signing."); + if (signature == null) + throw new IOException("Signing returned no signature."); + tm.sendMessage(new PacketUserauthRequestPublicKey("ssh-connection", user, + offer.algorithm, offer.blob, signature).getPayload()); - // Servers support different hash algorithms for RSA keys - // https://tools.ietf.org/html/draft-ietf-curdle-rsa-sha2-12 - Set algsAccepted = tm.getExtensionInfo().getSignatureAlgorithmsAccepted(); - final byte[] rsa_sig_enc; + byte[] ar = getNextMessage(); - if (algsAccepted.contains(RSASHA512Verify.get().getKeyFormat())) - { - SSHSignature s = RSASHA512Verify.get(); - pk_algorithm = s.getKeyFormat(); - byte[] msg = this.generatePublicKeyUserAuthenticationRequest(user, pk_algorithm, pk_enc); - if (signatureProxy != null) - { - rsa_sig_enc = signatureProxy.sign(msg, SignatureProxy.SHA512); - } - else - { - rsa_sig_enc = s.generateSignature(msg, privateKey, rnd); - } - } - else if (algsAccepted.contains(RSASHA256Verify.ID_RSA_SHA_2_256)) - { - pk_algorithm = RSASHA256Verify.ID_RSA_SHA_2_256; - byte[] msg = this.generatePublicKeyUserAuthenticationRequest(user, pk_algorithm, pk_enc); + return isAuthenticationSuccessful(ar); + } + catch (IOException e) + { + e.printStackTrace(); + tm.close(e, false); + throw new IOException("Publickey authentication failed.", e); + } + } - if (signatureProxy != null) - { - rsa_sig_enc = signatureProxy.sign(msg, SignatureProxy.SHA256); - } - else - { - rsa_sig_enc = RSASHA256Verify.get().generateSignature(msg, privateKey, rnd); - } - } - else - { - pk_algorithm = "ssh-rsa"; - byte[] msg = this.generatePublicKeyUserAuthenticationRequest(user, pk_algorithm, pk_enc); - if (signatureProxy != null) - { - rsa_sig_enc = signatureProxy.sign(msg, SignatureProxy.SHA1); - } - else - { - // Server always accepts RSA with SHA1 - rsa_sig_enc = RSASHA1Verify.get().generateSignature(msg, privateKey, rnd); - } - } + public boolean probePublicKey(String user, PublicKey key) throws IOException + { + try + { + initialize(user); - PacketUserauthRequestPublicKey ua = new PacketUserauthRequestPublicKey("ssh-connection", user, - pk_algorithm, pk_enc, rsa_sig_enc); + if (!methodPossible("publickey")) + throw new IOException("Authentication method publickey not supported by the server at this stage."); - tm.sendMessage(ua.getPayload()); - } - else if (publicKey instanceof ECPublicKey) - { - ECPublicKey ecPublicKey = (ECPublicKey) publicKey; + PublicKeyOffer offer = publicKeyOffer(key); + tm.sendMessage(new PacketUserauthRequestPublicKey("ssh-connection", user, + offer.algorithm, offer.blob, null).getPayload()); - ECDSASHA2Verify verifier = ECDSASHA2Verify.getVerifierForKey(ecPublicKey); + byte[] reply = getNextMessage(); - final String algo = verifier.getKeyFormat(); + if (reply[0] == Packets.SSH_MSG_USERAUTH_FAILURE) + return isAuthenticationSuccessful(reply); - byte[] pk_enc = verifier.encodePublicKey(ecPublicKey); + TypesReader reader = new TypesReader(reply); + if (reader.readByte() != Packets.SSH_MSG_USERAUTH_PK_OK) + throw new IOException("Unexpected reply to public key offer."); - byte[] msg = this.generatePublicKeyUserAuthenticationRequest(user, algo, pk_enc); + String algorithm = reader.readString(); + byte[] blob = reader.readByteString(); - byte[] ec_sig_enc; - if (signatureProxy != null) - { - ec_sig_enc = signatureProxy.sign(msg, ECDSASHA2Verify.getDigestAlgorithmForParams(ecPublicKey)); - } - else - { - ec_sig_enc = verifier.generateSignature(msg, privateKey, rnd); - } + if (reader.remain() != 0 || !offer.algorithm.equals(algorithm) || !Arrays.equals(offer.blob, blob)) + throw new IOException("Public key acceptance does not match the offered key."); - PacketUserauthRequestPublicKey ua = new PacketUserauthRequestPublicKey("ssh-connection", user, - algo, pk_enc, ec_sig_enc); + isPartialSuccess = false; + return true; + } + catch (IOException e) + { + tm.close(e, false); + throw new IOException("Public key offer failed.", e); + } + } - tm.sendMessage(ua.getPayload()); - } - else if (publicKey instanceof SkPublicKey) + private PublicKeyOffer publicKeyOffer(PublicKey key) throws IOException + { + SSHSignature verifier; + String hashAlgorithm; + if (key instanceof DSAPublicKey) + { + verifier = DSASHA1Verify.get(); + hashAlgorithm = SignatureProxy.SHA1; + } + else if (key instanceof RSAPublicKey) + { + Set algorithms = tm.getExtensionInfo().getSignatureAlgorithmsAccepted(); + if (algorithms.contains(RSASHA512Verify.get().getKeyFormat())) { - // FIDO2 Security Key (SK) authentication - // This check must come before the Ed25519 check because - // SK Ed25519 keys match isEd25519Key() but require SK-specific encoding. - if (signatureProxy == null) - { - throw new IOException("SK key authentication requires a SignatureProxy for signing."); - } - - SkPublicKey skPublicKey = (SkPublicKey) publicKey; - final String algo = skPublicKey.getSshKeyType(); - - // Get the encoded public key (includes key type, key data, and application) - byte[] pk_enc = skPublicKey.getEncoded(); - - byte[] msg = this.generatePublicKeyUserAuthenticationRequest(user, algo, pk_enc); - - // Determine the hash algorithm based on key type - // sk-ssh-ed25519@openssh.com uses SHA512 (same as Ed25519) - // sk-ecdsa-sha2-nistp256@openssh.com uses SHA256 - String hashAlgorithm; - if (algo.contains("ed25519")) - { - hashAlgorithm = SignatureProxy.SHA512; - } - else - { - hashAlgorithm = SignatureProxy.SHA256; - } - - // The SignatureProxy.sign() for SK keys must return the complete - // signature blob including flags and counter, not just the raw signature - byte[] sk_sig_enc = signatureProxy.sign(msg, hashAlgorithm); - - PacketUserauthRequestPublicKey ua = new PacketUserauthRequestPublicKey("ssh-connection", user, - algo, pk_enc, sk_sig_enc); - - tm.sendMessage(ua.getPayload()); + verifier = RSASHA512Verify.get(); + hashAlgorithm = SignatureProxy.SHA512; } - else if (PublicKeyUtils.isEd25519Key(publicKey)) + else if (algorithms.contains(RSASHA256Verify.ID_RSA_SHA_2_256)) { - final String algo = Ed25519Verify.ED25519_ID; - - byte[] pk_enc = Ed25519Verify.get().encodePublicKey(publicKey); - - byte[] msg = this.generatePublicKeyUserAuthenticationRequest(user, algo, pk_enc); - - byte[] ed_sig_enc; - if (signatureProxy != null) - { - ed_sig_enc = signatureProxy.sign(msg, SignatureProxy.SHA512); - } - else - { - Ed25519PrivateKey pk = Ed25519Verify.convertPrivateKey(privateKey); - ed_sig_enc = Ed25519Verify.get().generateSignature(msg, pk, rnd); - } - - PacketUserauthRequestPublicKey ua = new PacketUserauthRequestPublicKey("ssh-connection", user, - algo, pk_enc, ed_sig_enc); - - tm.sendMessage(ua.getPayload()); + verifier = RSASHA256Verify.get(); + hashAlgorithm = SignatureProxy.SHA256; } else { - throw new IOException("Unknown public key type."); + verifier = RSASHA1Verify.get(); + hashAlgorithm = SignatureProxy.SHA1; } + } + else if (key instanceof ECPublicKey) + { + ECPublicKey ecKey = (ECPublicKey) key; + verifier = ECDSASHA2Verify.getVerifierForKey(ecKey); + hashAlgorithm = ECDSASHA2Verify.getDigestAlgorithmForParams(ecKey); + } + else if (key instanceof SkPublicKey) + { + SkPublicKey skKey = (SkPublicKey) key; + String algorithm = skKey.getSshKeyType(); + return new PublicKeyOffer(algorithm, skKey.getEncoded(), null, + algorithm.contains("ed25519") ? SignatureProxy.SHA512 : SignatureProxy.SHA256); + } + else if (PublicKeyUtils.isEd25519Key(key)) + { + verifier = Ed25519Verify.get(); + hashAlgorithm = SignatureProxy.SHA512; + } + else + throw new IOException("Unknown public key type."); + return new PublicKeyOffer(verifier.getKeyFormat(), verifier.encodePublicKey(key), verifier, hashAlgorithm); + } - byte[] ar = getNextMessage(); + private static class PublicKeyOffer + { + final String algorithm; + final byte[] blob; + final SSHSignature verifier; + final String hashAlgorithm; - return isAuthenticationSuccessful(ar); - } - catch (IOException e) + PublicKeyOffer(String algorithm, byte[] blob, SSHSignature verifier, String hashAlgorithm) { - e.printStackTrace(); - tm.close(e, false); - throw new IOException("Publickey authentication failed.", e); + this.algorithm = algorithm; + this.blob = blob; + this.verifier = verifier; + this.hashAlgorithm = hashAlgorithm; } } diff --git a/src/main/java/com/trilead/ssh2/packets/PacketUserauthRequestPublicKey.java b/src/main/java/com/trilead/ssh2/packets/PacketUserauthRequestPublicKey.java index 4c6945ea..85e8cfd2 100644 --- a/src/main/java/com/trilead/ssh2/packets/PacketUserauthRequestPublicKey.java +++ b/src/main/java/com/trilead/ssh2/packets/PacketUserauthRequestPublicKey.java @@ -54,10 +54,11 @@ public byte[] getPayload() throws UnsupportedEncodingException { tw.writeString(userName, "UTF-8"); tw.writeString(serviceName); tw.writeString("publickey"); - tw.writeBoolean(true); + tw.writeBoolean(sig != null); tw.writeString(pkAlgoName); tw.writeString(pk, 0, pk.length); - tw.writeString(sig, 0, sig.length); + if (sig != null) + tw.writeString(sig, 0, sig.length); payload = tw.getBytes(); } return payload; diff --git a/src/main/java/com/trilead/ssh2/packets/Packets.java b/src/main/java/com/trilead/ssh2/packets/Packets.java index 3d00aa44..83b82db1 100644 --- a/src/main/java/com/trilead/ssh2/packets/Packets.java +++ b/src/main/java/com/trilead/ssh2/packets/Packets.java @@ -33,6 +33,7 @@ public class Packets public static final int SSH_MSG_USERAUTH_FAILURE = 51; public static final int SSH_MSG_USERAUTH_SUCCESS = 52; public static final int SSH_MSG_USERAUTH_BANNER = 53; + public static final int SSH_MSG_USERAUTH_PK_OK = 60; public static final int SSH_MSG_USERAUTH_INFO_REQUEST = 60; public static final int SSH_MSG_USERAUTH_INFO_RESPONSE = 61; diff --git a/src/test/java/com/trilead/ssh2/DropbearCompatibilityTest.java b/src/test/java/com/trilead/ssh2/DropbearCompatibilityTest.java index 6764edb2..a511618d 100644 --- a/src/test/java/com/trilead/ssh2/DropbearCompatibilityTest.java +++ b/src/test/java/com/trilead/ssh2/DropbearCompatibilityTest.java @@ -121,6 +121,21 @@ private void canConnectWithPubkey(String keyFilename) throws Exception { } } + @Test + public void publicKeyProbeThenSign() throws Exception { + char[] chars = IOUtils.toCharArray(getClass().getResourceAsStream("crypto/ecdsa-nistp256-openssh2-private-key.txt"), "UTF-8"); + java.security.KeyPair key = com.trilead.ssh2.crypto.PEMDecoder.decode(chars, ""); + try (Connection connection = withServer(server)) { + connection.connect(); + java.security.KeyPairGenerator generator = java.security.KeyPairGenerator.getInstance("EC"); + generator.initialize(256); + assertThat(connection.probePublicKey(USERNAME, generator.generateKeyPair().getPublic()), is(false)); + assertThat(connection.probePublicKey(USERNAME, key.getPublic()), is(true)); + assertThat(connection.isAuthenticationComplete(), is(false)); + assertThat(connection.authenticateWithPublicKey(USERNAME, key), is(true)); + } + } + @Test public void canConnectWithEd25519() throws Exception { canConnectWithPubkey("ed25519-openssh2-private-key.txt"); diff --git a/src/test/java/com/trilead/ssh2/OpenSSHCompatibilityTest.java b/src/test/java/com/trilead/ssh2/OpenSSHCompatibilityTest.java index 37806883..838bb17f 100644 --- a/src/test/java/com/trilead/ssh2/OpenSSHCompatibilityTest.java +++ b/src/test/java/com/trilead/ssh2/OpenSSHCompatibilityTest.java @@ -103,6 +103,59 @@ private void canConnectWithPubkey(String keyFilename) throws Exception { } } + @Test + public void publicKeyProbeThenSign() throws Exception { + char[] keyChars = IOUtils.toCharArray(getClass().getResourceAsStream("crypto/ecdsa-nistp256-openssh2-private-key.txt"), "UTF-8"); + java.security.KeyPair key = com.trilead.ssh2.crypto.PEMDecoder.decode(keyChars, ""); + try (GenericContainer server = getBaseContainer().withEnv(OPTIONS_ENV, "-o MaxAuthTries=1")) { + server.start(); + try (Connection connection = withServer(server)) { + connection.connect(verifier); + assertThat(connection.probePublicKey(USERNAME, key.getPublic()), is(true)); + assertThat(connection.isAuthenticationComplete(), is(false)); + assertThat(connection.authenticateWithPublicKey(USERNAME, key), is(true)); + } + } + } + + @Test + public void rejectedOffersExhaustProbeBudgetThenAuthLimit() throws Exception { + java.security.KeyPairGenerator generator = java.security.KeyPairGenerator.getInstance("EC"); + generator.initialize(256); + try (GenericContainer server = getBaseContainer().withEnv(OPTIONS_ENV, "-o MaxAuthTries=2")) { + server.start(); + try (Connection connection = withServer(server)) { + connection.connect(verifier); + // Current OpenSSH gives six probes a separate budget. The next + // rejection uses one auth attempt; the following one disconnects. + for (int i = 0; i < 7; i++) { + assertThat(connection.probePublicKey(USERNAME, generator.generateKeyPair().getPublic()), is(false)); + } + org.junit.jupiter.api.Assertions.assertThrows(IOException.class, + () -> connection.probePublicKey(USERNAME, generator.generateKeyPair().getPublic())); + } + } + } + + @Test + public void rejectedOffersThenAcceptedKeyWithinLimit() throws Exception { + char[] chars = IOUtils.toCharArray(getClass().getResourceAsStream("crypto/ecdsa-nistp256-openssh2-private-key.txt"), "UTF-8"); + java.security.KeyPair key = com.trilead.ssh2.crypto.PEMDecoder.decode(chars, ""); + java.security.KeyPairGenerator generator = java.security.KeyPairGenerator.getInstance("EC"); + generator.initialize(256); + try (GenericContainer server = getBaseContainer().withEnv(OPTIONS_ENV, "-o MaxAuthTries=1")) { + server.start(); + try (Connection connection = withServer(server)) { + connection.connect(verifier); + for (int i = 0; i < 6; i++) { + assertThat(connection.probePublicKey(USERNAME, generator.generateKeyPair().getPublic()), is(false)); + } + assertThat(connection.probePublicKey(USERNAME, key.getPublic()), is(true)); + assertThat(connection.authenticateWithPublicKey(USERNAME, key), is(true)); + } + } + } + @Test public void testSocketAddress() throws IOException { try (GenericContainer server = getBaseContainer()) { diff --git a/src/test/java/com/trilead/ssh2/auth/AuthenticationManagerProbeTest.java b/src/test/java/com/trilead/ssh2/auth/AuthenticationManagerProbeTest.java new file mode 100644 index 00000000..686e118d --- /dev/null +++ b/src/test/java/com/trilead/ssh2/auth/AuthenticationManagerProbeTest.java @@ -0,0 +1,248 @@ +/* + * Copyright 2026 Kenny Root + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions + * are met: + * + * a.) Redistributions of source code must retain the above copyright + * notice, this list of conditions and the following disclaimer. + * b.) Redistributions in binary form must reproduce the above copyright + * notice, this list of conditions and the following disclaimer in the + * documentation and/or other materials provided with the distribution. + * c.) Neither the name of Trilead nor the names of its contributors may + * be used to endorse or promote products derived from this software + * without specific prior written permission. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" + * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE + * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE + * ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE + * LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR + * CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF + * SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS + * INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN + * CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) + * ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE + * POSSIBILITY OF SUCH DAMAGE. + */ + +package com.trilead.ssh2.auth; + +import com.trilead.ssh2.ExtensionInfo; +import com.trilead.ssh2.crypto.PublicKeyUtils; +import com.trilead.ssh2.packets.PacketUserauthBanner; +import com.trilead.ssh2.packets.Packets; +import com.trilead.ssh2.packets.TypesReader; +import com.trilead.ssh2.packets.TypesWriter; +import com.trilead.ssh2.transport.TransportManager; +import java.io.IOException; +import java.security.KeyPair; +import java.security.KeyPairGenerator; +import java.util.ArrayList; +import java.util.List; +import java.util.Set; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; + +import static org.junit.jupiter.api.Assertions.assertArrayEquals; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyBoolean; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.doAnswer; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +class AuthenticationManagerProbeTest { + private TransportManager transport; + private AuthenticationManager manager; + private final List sent = new ArrayList<>(); + + @BeforeEach + void setup() throws Exception { + transport = mock(TransportManager.class); + manager = new AuthenticationManager(transport); + manager.initDone = true; + manager.remainingMethods = new String[] { "publickey" }; + ExtensionInfo extension = mock(ExtensionInfo.class); + when(extension.getSignatureAlgorithmsAccepted()).thenReturn(Set.of("rsa-sha2-512")); + when(transport.getExtensionInfo()).thenReturn(extension); + when(transport.getSessionIdentifier()).thenReturn(new byte[] { 1, 2, 3 }); + doAnswer(call -> { sent.add(call.getArgument(0)); return null; }).when(transport).sendMessage(any(byte[].class)); + } + + @ParameterizedTest + @ValueSource(strings = { "RSA", "EC", "Ed25519", "DSA" }) + void probeAndSignatureHaveSameAlgorithmAndKey(String type) throws Exception { + KeyPair key = key(type); + String algorithm = algorithm(type); + byte[] blob = PublicKeyUtils.extractPublicKeyBlob(key.getPublic()); + queue(acceptance(algorithm, blob)); + assertTrue(manager.probePublicKey("alice", key.getPublic())); + assertFalse(manager.authenticated); + assertFalse(manager.getPartialSuccess()); + TypesReader probe = request(sent.get(0)); + assertFalse(probe.readBoolean()); + assertEquals(algorithm, probe.readString()); + assertArrayEquals(blob, probe.readByteString()); + assertEquals(0, probe.remain()); + + SignatureProxy signer = mock(SignatureProxy.class); + when(signer.getPublicKey()).thenReturn(key.getPublic()); + when(signer.sign(any(byte[].class), any(String.class))).thenReturn(new byte[] { 9 }); + queue(new byte[] { Packets.SSH_MSG_USERAUTH_SUCCESS }); + assertTrue(manager.authenticatePublicKey("alice", signer)); + TypesReader signed = request(sent.get(1)); + assertTrue(signed.readBoolean()); + assertEquals(algorithm, signed.readString()); + assertArrayEquals(blob, signed.readByteString()); + assertArrayEquals(new byte[] { 9 }, signed.readByteString()); + assertEquals(0, signed.remain()); + verify(signer, times(1)).sign(any(byte[].class), any(String.class)); + } + + @Test + void rejectionUpdatesMethodsWithoutClosingTransport() throws Exception { + KeyPair key = key("EC"); + queue(failure("password", false)); + assertFalse(manager.probePublicKey("alice", key.getPublic())); + assertArrayEquals(new String[] { "password" }, manager.getRemainingMethods("alice")); + assertFalse(manager.authenticated); + verify(transport, never()).close(any(), anyBoolean()); + } + + @Test + void mismatchedAlgorithmClosesTransport() throws Exception { + KeyPair key = key("EC"); + queue(acceptance("ssh-rsa", PublicKeyUtils.extractPublicKeyBlob(key.getPublic()))); + assertThrows(IOException.class, () -> manager.probePublicKey("alice", key.getPublic())); + verify(transport).close(any(IOException.class), eq(false)); + } + + @Test + void mismatchedKeyClosesTransport() throws Exception { + KeyPair key = key("EC"); + queue(acceptance(algorithm("EC"), PublicKeyUtils.extractPublicKeyBlob(key("EC").getPublic()))); + assertThrows(IOException.class, () -> manager.probePublicKey("alice", key.getPublic())); + verify(transport).close(any(IOException.class), eq(false)); + } + + @Test + void malformedReplyClosesTransport() throws Exception { + queue(new byte[] { Packets.SSH_MSG_USERAUTH_PK_OK }); + assertThrows(IOException.class, () -> manager.probePublicKey("alice", key("EC").getPublic())); + verify(transport).close(any(IOException.class), eq(false)); + } + + @Test + void unexpectedSuccessDoesNotAuthenticateProbe() throws Exception { + queue(new byte[] { Packets.SSH_MSG_USERAUTH_SUCCESS }); + assertThrows(IOException.class, () -> manager.probePublicKey("alice", key("EC").getPublic())); + assertFalse(manager.authenticated); + } + + @Test + void bannersAreDeliveredWhileWaitingForAcceptance() throws Exception { + List banners = new ArrayList<>(); + manager.bannerCallbacks.add((message, language) -> banners.add(message)); + KeyPair key = key("EC"); + queue(new PacketUserauthBanner("hello", "en").getPayload()); + queue(acceptance(algorithm("EC"), PublicKeyUtils.extractPublicKeyBlob(key.getPublic()))); + assertTrue(manager.probePublicKey("alice", key.getPublic())); + assertEquals(List.of("hello"), banners); + } + + @ParameterizedTest + @ValueSource(strings = { "rsa-sha2-512", "rsa-sha2-256", "ssh-rsa" }) + void rsaProbeAndSignatureUseNegotiatedAlgorithm(String algorithm) throws Exception { + when(transport.getExtensionInfo().getSignatureAlgorithmsAccepted()).thenReturn(Set.of(algorithm)); + KeyPair key = key("RSA"); + byte[] blob = PublicKeyUtils.extractPublicKeyBlob(key.getPublic()); + queue(acceptance(algorithm, blob)); + assertTrue(manager.probePublicKey("alice", key.getPublic())); + queue(new byte[] { Packets.SSH_MSG_USERAUTH_SUCCESS }); + assertTrue(manager.authenticatePublicKey("alice", key, new java.security.SecureRandom())); + TypesReader signed = request(sent.get(1)); + assertTrue(signed.readBoolean()); + assertEquals(algorithm, signed.readString()); + assertArrayEquals(blob, signed.readByteString()); + assertTrue(signed.readByteString().length > 0); + } + + @ParameterizedTest + @ValueSource(strings = { "sk-ssh-ed25519@openssh.com", "sk-ecdsa-sha2-nistp256@openssh.com" }) + void securityKeyProbeUsesSecurityKeyBlob(String algorithm) throws Exception { + AuthenticationManagerSkKeyTest.TestSkPublicKey key = new AuthenticationManagerSkKeyTest.TestSkPublicKey( + algorithm, "ssh:", new byte[32]); + queue(acceptance(algorithm, key.getEncoded())); + assertTrue(manager.probePublicKey("alice", key)); + TypesReader probe = request(sent.get(0)); + assertFalse(probe.readBoolean()); + assertEquals(algorithm, probe.readString()); + assertArrayEquals(key.getEncoded(), probe.readByteString()); + assertEquals(0, probe.remain()); + } + + @Test + void missingSignatureDoesNotBecomeUnsignedAuthenticationRequest() throws Exception { + SignatureProxy signer = mock(SignatureProxy.class); + when(signer.getPublicKey()).thenReturn(key("EC").getPublic()); + assertThrows(IOException.class, () -> manager.authenticatePublicKey("alice", signer)); + assertTrue(sent.isEmpty()); + verify(transport).close(any(IOException.class), eq(false)); + } + + private void queue(byte[] message) throws IOException { + manager.handleMessage(message, message.length); + } + + private static KeyPair key(String type) throws Exception { + KeyPairGenerator generator = KeyPairGenerator.getInstance(type); + if (type.equals("RSA") || type.equals("DSA")) generator.initialize(1024); + if (type.equals("EC")) generator.initialize(256); + return generator.generateKeyPair(); + } + + private static String algorithm(String type) { + return switch (type) { + case "RSA" -> "rsa-sha2-512"; + case "EC" -> "ecdsa-sha2-nistp256"; + case "Ed25519" -> "ssh-ed25519"; + default -> "ssh-dss"; + }; + } + + private static TypesReader request(byte[] message) throws IOException { + TypesReader reader = new TypesReader(message); + assertEquals(Packets.SSH_MSG_USERAUTH_REQUEST, reader.readByte()); + assertEquals("alice", reader.readString()); + assertEquals("ssh-connection", reader.readString()); + assertEquals("publickey", reader.readString()); + return reader; + } + + private static byte[] acceptance(String algorithm, byte[] blob) { + TypesWriter writer = new TypesWriter(); + writer.writeByte(Packets.SSH_MSG_USERAUTH_PK_OK); + writer.writeString(algorithm); + writer.writeString(blob, 0, blob.length); + return writer.getBytes(); + } + + private static byte[] failure(String methods, boolean partial) { + TypesWriter writer = new TypesWriter(); + writer.writeByte(Packets.SSH_MSG_USERAUTH_FAILURE); + writer.writeString(methods); + writer.writeBoolean(partial); + return writer.getBytes(); + } +} diff --git a/src/test/java/com/trilead/ssh2/channel/AuthAgentForwardThreadTest.java b/src/test/java/com/trilead/ssh2/channel/AuthAgentForwardThreadTest.java new file mode 100644 index 00000000..091d7d53 --- /dev/null +++ b/src/test/java/com/trilead/ssh2/channel/AuthAgentForwardThreadTest.java @@ -0,0 +1,91 @@ +/* + * Copyright 2026 Kenny Root + * SPDX-License-Identifier: BSD-3-Clause + */ +package com.trilead.ssh2.channel; + +import com.trilead.ssh2.AuthAgentCallback; +import com.trilead.ssh2.packets.TypesReader; +import com.trilead.ssh2.packets.TypesWriter; +import com.trilead.ssh2.signature.ECDSASHA2Verify; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.CsvSource; + +import java.io.ByteArrayOutputStream; +import java.lang.reflect.Method; +import java.math.BigInteger; +import java.nio.charset.StandardCharsets; +import java.security.KeyPair; +import java.security.KeyPairGenerator; +import java.security.interfaces.ECPrivateKey; +import java.security.interfaces.ECPublicKey; +import java.security.spec.ECFieldFp; +import java.security.spec.ECGenParameterSpec; +import java.security.spec.ECParameterSpec; +import java.security.spec.ECPoint; +import java.security.spec.EllipticCurve; + +import static org.junit.jupiter.api.Assertions.assertArrayEquals; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +public class AuthAgentForwardThreadTest { + + private static final byte[] CHALLENGE = "agent signing challenge".getBytes(StandardCharsets.UTF_8); + + private byte[] sign(KeyPair pair, byte[] publicKey, int flags) throws Exception { + AuthAgentCallback callback = mock(AuthAgentCallback.class); + when(callback.getKeyPair(publicKey)).thenReturn(pair); + AuthAgentForwardThread agent = new AuthAgentForwardThread(null, callback); + ByteArrayOutputStream output = new ByteArrayOutputStream(); + agent.os = output; + + TypesWriter request = new TypesWriter(); + request.writeString(publicKey, 0, publicKey.length); + request.writeString(CHALLENGE, 0, CHALLENGE.length); + request.writeUINT32(flags); + + Method signRequest = AuthAgentForwardThread.class.getDeclaredMethod("processSignRequest", TypesReader.class); + signRequest.setAccessible(true); + signRequest.invoke(agent, new TypesReader(request.getBytes())); + return output.toByteArray(); + } + + @ParameterizedTest + @CsvSource({ + "secp256r1, ecdsa-sha2-nistp256", + "secp384r1, ecdsa-sha2-nistp384", + "secp521r1, ecdsa-sha2-nistp521" + }) + public void signsEcdsaChallenge(String curve, String algorithm) throws Exception { + KeyPairGenerator generator = KeyPairGenerator.getInstance("EC"); + generator.initialize(new ECGenParameterSpec(curve)); + KeyPair pair = generator.generateKeyPair(); + ECDSASHA2Verify verifier = ECDSASHA2Verify.getVerifierForKey((ECPublicKey) pair.getPublic()); + byte[] publicKey = verifier.encodePublicKey(pair.getPublic()); + + byte[] packet = sign(pair, publicKey, 0); + TypesReader response = new TypesReader(packet); + assertEquals(packet.length - 4, response.readUINT32()); + assertEquals(14, response.readByte()); + byte[] signature = response.readByteString(); + assertEquals(0, response.remain()); + assertEquals(algorithm, new TypesReader(signature).readString()); + assertTrue(verifier.verifySignature(CHALLENGE, signature, pair.getPublic())); + assertFalse(verifier.verifySignature(new byte[]{1}, signature, pair.getPublic())); + } + + @Test + public void refusesUnsupportedEcCurve() throws Exception { + ECPrivateKey privateKey = mock(ECPrivateKey.class); + EllipticCurve curve = new EllipticCurve(new ECFieldFp(BigInteger.ONE.shiftLeft(255).subtract(BigInteger.valueOf(19))), + BigInteger.ONE, BigInteger.ONE); + when(privateKey.getParams()).thenReturn(new ECParameterSpec(curve, new ECPoint(BigInteger.ONE, BigInteger.ONE), + BigInteger.valueOf(3), 1)); + assertArrayEquals(new byte[]{0, 0, 0, 1, 5}, sign(new KeyPair(null, privateKey), new byte[]{1}, 0)); + } +}