This is an automated email from the ASF dual-hosted git repository. twolf pushed a commit to branch dev_3.0 in repository https://gitbox.apache.org/repos/asf/mina-sshd.git
commit e7baf5dca57c45c80e14b9492a1e6b1e22cbda9c Author: Thomas Wolf <tw...@apache.org> AuthorDate: Fri Apr 25 23:59:29 2025 +0200 Finish the "hostkeys...@openssh.com" host key rotation implementation Implement the client side as far as registering the host keys in the client session: send the signature challenge, and handle the reply. Once the keys are verified, invoke a new NewHostKeysHandler on the SshClient. (It gets the session as parameter so that it can figure out which server sent the keys. But the handler is installed on the client because if it updates the known_hosts file, that could affect many sessions.) Take care to handle certificates and RSA keys. Fix the server side to correctly deal with these, too. Updating the known_hosts file is not done; user code can install its own NewHostKeysHandler on the SshClient to do so. Remove the nested OpenSshCertificate.CertificateOption class; these options are better handled as Map<String, String>. Remove unnecessary generics in arguments: replace Collection<? extends PublicKey> by Collection<PublicKey>. --- CHANGES.md | 1 + .../apache/sshd/common/config/keys/KeyUtils.java | 13 +- .../common/config/keys/OpenSshCertificate.java | 119 ++++------------ .../common/config/keys/OpenSshCertificateImpl.java | 60 +------- .../org/apache/sshd/common/util/GenericUtils.java | 2 +- .../org/apache/sshd/common/util/buffer/Buffer.java | 30 ++-- .../certificate/OpenSshCertificateBuilder.java | 28 ---- .../java/org/apache/sshd/client/ClientBuilder.java | 13 ++ .../apache/sshd/client/ClientFactoryManager.java | 5 + .../java/org/apache/sshd/client/SshClient.java | 12 ++ .../client/config/DefaultNewHostKeysHandler.java | 42 ++++++ .../sshd/client/config/NewHostKeysHandler.java | 50 +++++++ .../sshd/client/global/OpenSshHostKeysHandler.java | 155 ++++++++++++++++++++- .../java/org/apache/sshd/client/kex/DHGClient.java | 16 +-- .../sshd/client/session/AbstractClientSession.java | 83 ++++++----- .../apache/sshd/client/session/ClientSession.java | 40 +++++- .../global/AbstractOpenSshHostKeysHandler.java | 2 +- .../sshd/server/auth/pubkey/UserAuthPublicKey.java | 20 +-- .../sshd/server/global/OpenSshHostKeysHandler.java | 27 +++- .../GenerateOpenSSHClientCertificateTest.java | 25 ++-- ...GenerateOpenSshClientCertificateOracleTest.java | 21 +-- .../certificates/OpenSSHCertificateParserTest.java | 9 +- .../auth/pubkey/HostBoundPubKeyAuthTest.java | 29 +++- .../common/global/OpenSshHostKeysHandlerTest.java | 4 +- .../sshd/common/session/GlobalRequestTest.java | 8 +- 25 files changed, 498 insertions(+), 316 deletions(-) diff --git a/CHANGES.md b/CHANGES.md index 62a37ab9b..0a1d485c5 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -24,3 +24,4 @@ Version 3 includes all the features and bug fixes of version 2, including the [l * New event callback `SessionListener.sessionStarting()`. See the [filter documentation](./docs/technical/filters.md). `SessionListener.sessionEstablished()` was removed; it was called from the constructor of `AbstractSession` at a time when the object was not yet fully initialized. * [GH-728](https://github.com/apache/mina-sshd/issues/728) New method `ClientSession.getHostConfigEntry()` to get the `HostConfigEntry` for the session. * [GH-729](https://github.com/apache/mina-sshd/issues/729) Support for client-side SOCKS5 or HTTP CONNECT proxies. See the [documentation](./docs/client-setup.md#proxies). +* The [OpenSSH "hostkeys...@openssh.com" host key rotation extension](https://github.com/openssh/openssh-portable/blob/b5b405fee/PROTOCOL#L367) is now implemented client-side. New host keys so received are registered on the session but we don't update the known_hosts file. If you want that, implement your own `NewHostKeysHandler` and set it on the `SshClient`. diff --git a/sshd-common/src/main/java/org/apache/sshd/common/config/keys/KeyUtils.java b/sshd-common/src/main/java/org/apache/sshd/common/config/keys/KeyUtils.java index 5de8d0b3c..046ed3b88 100644 --- a/sshd-common/src/main/java/org/apache/sshd/common/config/keys/KeyUtils.java +++ b/sshd-common/src/main/java/org/apache/sshd/common/config/keys/KeyUtils.java @@ -168,9 +168,6 @@ public final class KeyUtils { static { - // order matters here, when, for example, SkED25519PublicKeyEntryDecoder is used, it registers the PrivateKey - // type as java.security.PrivateKey, and all PrivateKey types are assignable to this, so it must be consulted last - registerPublicKeyEntryDecoder(OpenSSHCertificateDecoder.INSTANCE); registerPublicKeyEntryDecoder(RSAPublicKeyDecoder.INSTANCE); registerPublicKeyEntryDecoder(DSSPublicKeyEntryDecoder.INSTANCE); @@ -182,9 +179,6 @@ public final class KeyUtils { registerPublicKeyEntryDecoder(SecurityUtils.getEDDSAPublicKeyEntryDecoder()); } - // order matters, these must be last since they register their PrivateKey type as java.security.PrivateKey - // there is logical code which discovers a decoder type by instance assignability to this registered type - if (SecurityUtils.isECCSupported()) { registerPublicKeyEntryDecoder(SkECDSAPublicKeyEntryDecoder.INSTANCE); } @@ -1182,6 +1176,13 @@ public final class KeyUtils { } } + public static String getSignatureAlgorithm(String chosenAlgorithm) { + synchronized (SIGNATURE_ALGORITHM_MAP) { + String mapped = SIGNATURE_ALGORITHM_MAP.get(chosenAlgorithm); + return mapped == null ? chosenAlgorithm : mapped; + } + } + public static boolean isCertificateAlgorithm(String algorithm) { synchronized (SIGNATURE_ALGORITHM_MAP) { return SIGNATURE_ALGORITHM_MAP.containsKey(algorithm); diff --git a/sshd-common/src/main/java/org/apache/sshd/common/config/keys/OpenSshCertificate.java b/sshd-common/src/main/java/org/apache/sshd/common/config/keys/OpenSshCertificate.java index ab3e230a1..ef6a5899f 100644 --- a/sshd-common/src/main/java/org/apache/sshd/common/config/keys/OpenSshCertificate.java +++ b/sshd-common/src/main/java/org/apache/sshd/common/config/keys/OpenSshCertificate.java @@ -24,10 +24,11 @@ import java.util.Arrays; import java.util.Collection; import java.util.Collections; import java.util.List; -import java.util.Objects; import java.util.SortedMap; import java.util.concurrent.TimeUnit; +import org.apache.sshd.common.NamedFactory; +import org.apache.sshd.common.signature.Signature; import org.apache.sshd.common.util.ValidateUtils; /** @@ -144,35 +145,19 @@ public interface OpenSshCertificate extends SshPublicKey { */ long getValidBefore(); - /** - * Retrieves the critical options set in the certificate. - * - * @return the critical options as an unmodifiable list, never {@code null} but possibly empty - * @see #getCriticalOptionsMap() - */ - List<CertificateOption> getCriticalOptions(); - /** * Retrieves the critical options set in the certificate. * * @return the critical options as an unmodifiable map, never {@code null} but possibly empty */ - SortedMap<String, String> getCriticalOptionsMap(); - - /** - * Retrieves the extensions set in the certificate. - * - * @return the extensions as an unmodifiable list, never {@code null} but possibly empty - * @see #getExtensionsMap() - */ - List<CertificateOption> getExtensions(); + SortedMap<String, String> getCriticalOptions(); /** * Retrieves the extensions set in the certificate. * * @return the extensions as an unmodifiable map, never {@code null} but possibly empty */ - SortedMap<String, String> getExtensionsMap(); + SortedMap<String, String> getExtensions(); /** * Retrieves the "reserved" field of the certificate. OpenSSH currently doesn't use it and ignores it. @@ -244,86 +229,32 @@ public interface OpenSshCertificate extends SshPublicKey { } /** - * Certificate Options are a set of bytes that is + * Verifies the signature of the certificate. * - * <pre> - * [overall length][name(string)][[length of buffer][[length of string][data(string)]]]... - * </pre> - * <p> - * Where each Certificate Option is encoded as a name (string) and data (string packed in a buffer). The entire name - * (string) + data (buffer) are added as bytes (which will get a length prefix). - * </p> - * - * @see <a href= - * "https://github.com/openssh/openssh-portable/blob/master/PROTOCOL.certkeys#L221-L319">PROTOCOL.certkeys</a> + * @param cert {@link OpenSshCertificate} to verify + * @param signatureFactories Available signature factories for the signature verification + * @return {@code true} if the signature verifies, {@code false} otherwise + * @throws Exception if the signature verifier reports an error */ - class CertificateOption { - - private final String name; - private final String data; - - /** - * Creates a new {@link CertificateOption} with the given name and data. - * - * @param name of the option; must be neither {@code null} nor empty - * @param data for the option; may be {@code null} or empty - */ - public CertificateOption(String name, String data) { - this.name = ValidateUtils.checkNotNullAndNotEmpty(name, "CertificateOption name must be set"); - this.data = data; - } - - /** - * Creates a new {@link CertificateOption} with a name without data. - * - * @param name of the option; must be neither {@code null} nor empty - */ - public CertificateOption(String name) { - this(name, null); - } - - /** - * Retrieves the name. - * - * @return the name, never {@code null} - */ - public final String getName() { - return name; - } - - /** - * Retrieves the data. - * - * @return the data, may be{@code null} or empty - */ - public final String getData() { - return data; + static boolean verifySignature(OpenSshCertificate cert, List<NamedFactory<Signature>> signatureFactories) + throws Exception { + PublicKey signatureKey = cert.getCaPubKey(); + if (signatureKey instanceof OpenSshCertificate || cert.getCertPubKey() instanceof OpenSshCertificate) { + // OpenSSH certificates do not allow certificate chains. + return false; } - - @Override - public String toString() { - return "CertificateOption{name='" + name + "', data='" + data + "'}"; + String keyAlg = KeyUtils.getKeyType(signatureKey); + String sigAlg = cert.getSignatureAlgorithm(); + if (!keyAlg.equals(KeyUtils.getCanonicalKeyType(sigAlg))) { + return false; } - - @Override - public boolean equals(Object obj) { - if (obj == null) { - return false; - } - if (obj == this) { - return true; - } - if (getClass() != obj.getClass()) { - return false; - } - - CertificateOption other = (CertificateOption) obj; - return Objects.equals(name, other.name) && Objects.equals(data, other.data); + Signature verifier = NamedFactory.create(signatureFactories, sigAlg); + if (verifier == null) { + return false; } + verifier.initVerifier(null, signatureKey); + verifier.update(null, cert.getMessage()); - @Override - public int hashCode() { - return Objects.hash(name, data); - } + return verifier.verify(null, cert.getSignature()); } } diff --git a/sshd-common/src/main/java/org/apache/sshd/common/config/keys/OpenSshCertificateImpl.java b/sshd-common/src/main/java/org/apache/sshd/common/config/keys/OpenSshCertificateImpl.java index f2c182b54..b8e126520 100644 --- a/sshd-common/src/main/java/org/apache/sshd/common/config/keys/OpenSshCertificateImpl.java +++ b/sshd-common/src/main/java/org/apache/sshd/common/config/keys/OpenSshCertificateImpl.java @@ -20,11 +20,9 @@ package org.apache.sshd.common.config.keys; import java.security.PublicKey; import java.time.Instant; -import java.util.ArrayList; import java.util.Collection; import java.util.Collections; import java.util.Date; -import java.util.List; import java.util.Map; import java.util.SortedMap; import java.util.TreeMap; @@ -116,32 +114,12 @@ public class OpenSshCertificateImpl implements OpenSshCertificate { } @Override - public List<CertificateOption> getCriticalOptions() { - if (criticalOptions == null || criticalOptions.isEmpty()) { - return Collections.emptyList(); - } - List<CertificateOption> list = new ArrayList<>(criticalOptions.size()); - criticalOptions.forEach((k, v) -> list.add(new CertificateOption(k, v))); - return Collections.unmodifiableList(list); - } - - @Override - public SortedMap<String, String> getCriticalOptionsMap() { + public SortedMap<String, String> getCriticalOptions() { return criticalOptions == null ? Collections.emptySortedMap() : Collections.unmodifiableSortedMap(criticalOptions); } @Override - public List<CertificateOption> getExtensions() { - if (extensions == null || extensions.isEmpty()) { - return Collections.emptyList(); - } - List<CertificateOption> list = new ArrayList<>(extensions.size()); - extensions.forEach((k, v) -> list.add(new CertificateOption(k, v))); - return Collections.unmodifiableList(list); - } - - @Override - public SortedMap<String, String> getExtensionsMap() { + public SortedMap<String, String> getExtensions() { return extensions == null ? Collections.emptySortedMap() : Collections.unmodifiableSortedMap(extensions); } @@ -261,23 +239,6 @@ public class OpenSshCertificateImpl implements OpenSshCertificate { } } - /** - * Sets the critical options of the certificate, overriding any options set earlier. - * - * @param criticalOptions to set; may be {@code null} or empty to remove all previously set options - */ - public void setCriticalOptions(List<CertificateOption> criticalOptions) { - if (criticalOptions == null || criticalOptions.isEmpty()) { - this.criticalOptions = null; - } else { - this.criticalOptions = new TreeMap<>(); - for (CertificateOption option : criticalOptions) { - String data = option.getData(); - this.criticalOptions.put(option.getName(), data == null ? "" : data); - } - } - } - /** * Sets the critical options of the certificate, overriding any options set earlier. * @@ -317,23 +278,6 @@ public class OpenSshCertificateImpl implements OpenSshCertificate { return criticalOptions.put(key, value) == null; } - /** - * Sets the extensions of the certificate, overriding any extensions set earlier. - * - * @param extensions to set; may be {@code null} or empty to remove all previously set extensions - */ - public void setExtensions(List<CertificateOption> extensions) { - if (extensions == null || extensions.isEmpty()) { - this.extensions = null; - } else { - this.extensions = new TreeMap<>(); - for (CertificateOption option : extensions) { - String data = option.getData(); - this.extensions.put(option.getName(), data == null ? "" : data); - } - } - } - /** * Sets the extensions of the certificate, overriding any extensions set earlier. * diff --git a/sshd-common/src/main/java/org/apache/sshd/common/util/GenericUtils.java b/sshd-common/src/main/java/org/apache/sshd/common/util/GenericUtils.java index 57ffec51a..d2fe8e91a 100644 --- a/sshd-common/src/main/java/org/apache/sshd/common/util/GenericUtils.java +++ b/sshd-common/src/main/java/org/apache/sshd/common/util/GenericUtils.java @@ -355,7 +355,7 @@ public final class GenericUtils { } public static boolean isEmpty(Collection<?> c) { - return size(c) <= 0; + return c == null || c.isEmpty(); } public static boolean isNotEmpty(Collection<?> c) { diff --git a/sshd-common/src/main/java/org/apache/sshd/common/util/buffer/Buffer.java b/sshd-common/src/main/java/org/apache/sshd/common/util/buffer/Buffer.java index bb7bfc2d8..ee857d6b4 100644 --- a/sshd-common/src/main/java/org/apache/sshd/common/util/buffer/Buffer.java +++ b/sshd-common/src/main/java/org/apache/sshd/common/util/buffer/Buffer.java @@ -49,8 +49,10 @@ import java.util.ArrayList; import java.util.Arrays; import java.util.Collection; import java.util.Collections; +import java.util.HashMap; import java.util.LinkedList; import java.util.List; +import java.util.Map; import java.util.Objects; import java.util.function.IntUnaryOperator; import java.util.logging.Level; @@ -371,7 +373,7 @@ public abstract class Buffer implements Readable { return GenericUtils.isEmpty(values) ? Collections.emptyList() : Arrays.asList(values); } - public List<OpenSshCertificate.CertificateOption> getCertificateOptions() { + public Map<String, String> getCertificateOptions() { return getCertificateOptions(StandardCharsets.UTF_8); } @@ -390,8 +392,8 @@ public abstract class Buffer implements Readable { * @param charset {@link Charset} to use for converting bytes to characters * @return the parsed result, never {@code null}, but possibly empty */ - public List<OpenSshCertificate.CertificateOption> getCertificateOptions(Charset charset) { - List<OpenSshCertificate.CertificateOption> list = new ArrayList<>(); + public Map<String, String> getCertificateOptions(Charset charset) { + Map<String, String> result = new HashMap<>(); if (available() > 0) { // pull out entire Certificate Options section @@ -405,11 +407,11 @@ public abstract class Buffer implements Readable { data = GenericUtils.trimToEmpty(dataBuffer.getString(charset)); data = data.length() > 0 ? data : null; } - list.add(new OpenSshCertificate.CertificateOption(name, data)); + result.put(name, data); } } - return list; + return result; } /** @@ -841,7 +843,7 @@ public abstract class Buffer implements Readable { } } - public void putCertificateOptions(List<OpenSshCertificate.CertificateOption> options) { + public void putCertificateOptions(Map<String, String> options) { putCertificateOptions(options, StandardCharsets.UTF_8); } @@ -860,26 +862,24 @@ public abstract class Buffer implements Readable { * @param options to write into the buffer, may be {@code null} or empty but must not contain {@code null} elements * @param charset The {@link Charset} to use for string options */ - public void putCertificateOptions(List<OpenSshCertificate.CertificateOption> options, Charset charset) { - int numObjects = GenericUtils.size(options); - - if (numObjects <= 0) { + public void putCertificateOptions(Map<String, String> options, Charset charset) { + if (options == null || options.size() == 0) { putBytes(GenericUtils.EMPTY_BYTE_ARRAY); return; } ByteArrayBuffer optionBuffer = new ByteArrayBuffer(); - for (OpenSshCertificate.CertificateOption option : options) { - optionBuffer.putString(option.getName(), charset); - if (GenericUtils.isEmpty(option.getData())) { + options.forEach((key, value) -> { + optionBuffer.putString(key, charset); + if (GenericUtils.isEmpty(value)) { optionBuffer.putBytes(GenericUtils.EMPTY_BYTE_ARRAY); } else { ByteArrayBuffer dataBuffer = new ByteArrayBuffer(); - dataBuffer.putString(option.getData(), charset); + dataBuffer.putString(value, charset); optionBuffer.putBytes(dataBuffer.getCompactData()); } - } + }); putBytes(optionBuffer.getCompactData()); } diff --git a/sshd-core/src/main/java/org/apache/sshd/certificate/OpenSshCertificateBuilder.java b/sshd-core/src/main/java/org/apache/sshd/certificate/OpenSshCertificateBuilder.java index 5d5f51ed8..46c5977ed 100644 --- a/sshd-core/src/main/java/org/apache/sshd/certificate/OpenSshCertificateBuilder.java +++ b/sshd-core/src/main/java/org/apache/sshd/certificate/OpenSshCertificateBuilder.java @@ -26,14 +26,12 @@ import java.time.Instant; import java.util.ArrayList; import java.util.Collection; import java.util.HashMap; -import java.util.List; import java.util.Map; import org.apache.sshd.common.BaseBuilder; import org.apache.sshd.common.NamedFactory; import org.apache.sshd.common.config.keys.KeyUtils; import org.apache.sshd.common.config.keys.OpenSshCertificate; -import org.apache.sshd.common.config.keys.OpenSshCertificate.CertificateOption; import org.apache.sshd.common.config.keys.OpenSshCertificate.Type; import org.apache.sshd.common.config.keys.OpenSshCertificateImpl; import org.apache.sshd.common.keyprovider.KeyPairProvider; @@ -112,19 +110,6 @@ public class OpenSshCertificateBuilder { return this; } - public OpenSshCertificateBuilder criticalOptions(List<OpenSshCertificate.CertificateOption> criticalOptions) { - this.criticalOptions.clear(); - for (CertificateOption option : criticalOptions) { - String key = ValidateUtils.checkNotNullAndNotEmpty(option.getName(), "Critical option name must be set"); - String value = option.getData(); - String prev = this.criticalOptions.put(key, value == null ? "" : value); - if (prev != null) { - throw new IllegalArgumentException("Duplicate certificate option " + key); - } - } - return this; - } - public OpenSshCertificateBuilder criticalOptions(Map<String, String> criticalOptions) { this.criticalOptions.clear(); criticalOptions.forEach((k, v) -> { @@ -140,19 +125,6 @@ public class OpenSshCertificateBuilder { return this; } - public OpenSshCertificateBuilder extensions(List<OpenSshCertificate.CertificateOption> extensions) { - this.extensions.clear(); - for (CertificateOption option : extensions) { - String key = ValidateUtils.checkNotNullAndNotEmpty(option.getName(), "Extension name must be set"); - String value = option.getData(); - String prev = this.extensions.put(key, value == null ? "" : value); - if (prev != null) { - throw new IllegalArgumentException("Duplicate certificate extension " + key); - } - } - return this; - } - public OpenSshCertificateBuilder extensions(Map<String, String> extensions) { this.extensions.clear(); extensions.forEach((k, v) -> { diff --git a/sshd-core/src/main/java/org/apache/sshd/client/ClientBuilder.java b/sshd-core/src/main/java/org/apache/sshd/client/ClientBuilder.java index 7876431ee..eec0bc53b 100644 --- a/sshd-core/src/main/java/org/apache/sshd/client/ClientBuilder.java +++ b/sshd-core/src/main/java/org/apache/sshd/client/ClientBuilder.java @@ -24,6 +24,8 @@ import java.util.Collections; import java.util.List; import java.util.function.Function; +import org.apache.sshd.client.config.DefaultNewHostKeysHandler; +import org.apache.sshd.client.config.NewHostKeysHandler; import org.apache.sshd.client.config.hosts.DefaultConfigFileHostEntryResolver; import org.apache.sshd.client.config.hosts.HostConfigEntryResolver; import org.apache.sshd.client.config.keys.ClientIdentityLoader; @@ -85,6 +87,7 @@ public class ClientBuilder extends BaseBuilder<SshClient, ClientBuilder> { protected ProxyDataFactory proxyDataFactory; protected ServerKeyVerifier serverKeyVerifier; protected HostConfigEntryResolver hostConfigEntryResolver; + protected NewHostKeysHandler newHostKeysHandler; protected ClientIdentityLoader clientIdentityLoader; protected FilePasswordProvider filePasswordProvider; @@ -107,6 +110,11 @@ public class ClientBuilder extends BaseBuilder<SshClient, ClientBuilder> { return me(); } + public ClientBuilder newHostKeysHandler(NewHostKeysHandler handler) { + this.newHostKeysHandler = handler; + return me(); + } + public ClientBuilder clientIdentityLoader(ClientIdentityLoader loader) { this.clientIdentityLoader = loader; return me(); @@ -157,6 +165,10 @@ public class ClientBuilder extends BaseBuilder<SshClient, ClientBuilder> { hostConfigEntryResolver = DEFAULT_HOST_CONFIG_ENTRY_RESOLVER; } + if (newHostKeysHandler == null) { + newHostKeysHandler = new DefaultNewHostKeysHandler(); + } + if (clientIdentityLoader == null) { clientIdentityLoader = DEFAULT_CLIENT_IDENTITY_LOADER; } @@ -178,6 +190,7 @@ public class ClientBuilder extends BaseBuilder<SshClient, ClientBuilder> { client.setProxyDataFactory(proxyDataFactory); client.setServerKeyVerifier(serverKeyVerifier); client.setHostConfigEntryResolver(hostConfigEntryResolver); + client.setNewHostKeysHandler(newHostKeysHandler); client.setClientIdentityLoader(clientIdentityLoader); client.setFilePasswordProvider(filePasswordProvider); return client; diff --git a/sshd-core/src/main/java/org/apache/sshd/client/ClientFactoryManager.java b/sshd-core/src/main/java/org/apache/sshd/client/ClientFactoryManager.java index d16b7e592..bbee78c8a 100644 --- a/sshd-core/src/main/java/org/apache/sshd/client/ClientFactoryManager.java +++ b/sshd-core/src/main/java/org/apache/sshd/client/ClientFactoryManager.java @@ -18,6 +18,7 @@ */ package org.apache.sshd.client; +import org.apache.sshd.client.config.NewHostKeysHandler; import org.apache.sshd.client.config.hosts.HostConfigEntryResolver; import org.apache.sshd.client.config.keys.ClientIdentityLoaderManager; import org.apache.sshd.client.session.ClientSessionCreator; @@ -44,4 +45,8 @@ public interface ClientFactoryManager HostConfigEntryResolver getHostConfigEntryResolver(); void setHostConfigEntryResolver(HostConfigEntryResolver resolver); + + NewHostKeysHandler getNewHostKeysHandler(); + + void setNewHostKeysHandler(NewHostKeysHandler handler); } diff --git a/sshd-core/src/main/java/org/apache/sshd/client/SshClient.java b/sshd-core/src/main/java/org/apache/sshd/client/SshClient.java index 5778bd426..22dad5edc 100644 --- a/sshd-core/src/main/java/org/apache/sshd/client/SshClient.java +++ b/sshd-core/src/main/java/org/apache/sshd/client/SshClient.java @@ -59,6 +59,7 @@ import org.apache.sshd.client.auth.password.PasswordIdentityProvider; import org.apache.sshd.client.auth.password.UserAuthPasswordFactory; import org.apache.sshd.client.auth.pubkey.PublicKeyAuthenticationReporter; import org.apache.sshd.client.auth.pubkey.UserAuthPublicKeyFactory; +import org.apache.sshd.client.config.NewHostKeysHandler; import org.apache.sshd.client.config.hosts.HostConfigEntry; import org.apache.sshd.client.config.hosts.HostConfigEntryResolver; import org.apache.sshd.client.config.keys.ClientIdentity; @@ -195,6 +196,7 @@ public class SshClient extends AbstractFactoryManager implements ClientFactoryMa private ProxyDataFactory proxyDataFactory; private ServerKeyVerifier serverKeyVerifier; private HostConfigEntryResolver hostConfigEntryResolver; + private NewHostKeysHandler newHostKeysHandler; private ClientIdentityLoader clientIdentityLoader; private KeyIdentityProvider keyIdentityProvider; private PublicKeyAuthenticationReporter publicKeyAuthenticationReporter; @@ -248,6 +250,16 @@ public class SshClient extends AbstractFactoryManager implements ClientFactoryMa this.hostConfigEntryResolver = Objects.requireNonNull(resolver, "No host configuration entry resolver"); } + @Override + public NewHostKeysHandler getNewHostKeysHandler() { + return newHostKeysHandler; + } + + @Override + public void setNewHostKeysHandler(NewHostKeysHandler handler) { + newHostKeysHandler = Objects.requireNonNull(handler, "No handler for new host keys"); + } + @Override public FilePasswordProvider getFilePasswordProvider() { return filePasswordProvider; diff --git a/sshd-core/src/main/java/org/apache/sshd/client/config/DefaultNewHostKeysHandler.java b/sshd-core/src/main/java/org/apache/sshd/client/config/DefaultNewHostKeysHandler.java new file mode 100644 index 000000000..f8751f5e8 --- /dev/null +++ b/sshd-core/src/main/java/org/apache/sshd/client/config/DefaultNewHostKeysHandler.java @@ -0,0 +1,42 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you 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.apache.sshd.client.config; + +import java.security.PublicKey; +import java.util.Collection; + +import org.apache.sshd.client.session.ClientSession; + +/** + * A default {@link NewHostKeysHandler} that registers the new host keys on the session. + * + * @author <a href="mailto:d...@mina.apache.org">Apache MINA SSHD Project</a> + */ +public class DefaultNewHostKeysHandler implements NewHostKeysHandler { + + public DefaultNewHostKeysHandler() { + super(); + } + + @Override + public void receiveNewHostKeys(ClientSession session, Collection<PublicKey> hostKeys) { + hostKeys.forEach(session::registerHostKey); + } + +} diff --git a/sshd-core/src/main/java/org/apache/sshd/client/config/NewHostKeysHandler.java b/sshd-core/src/main/java/org/apache/sshd/client/config/NewHostKeysHandler.java new file mode 100644 index 000000000..ec5270581 --- /dev/null +++ b/sshd-core/src/main/java/org/apache/sshd/client/config/NewHostKeysHandler.java @@ -0,0 +1,50 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you 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.apache.sshd.client.config; + +import java.security.PublicKey; +import java.util.Collection; + +import org.apache.sshd.client.session.ClientSession; + +/** + * A handler that knows what to do when a {@link ClientSession} got new host keys from a server via the OpenSSH + * "hostkeys...@openssh.com" host key rotation extension. + * + * @author <a href="mailto:d...@mina.apache.org">Apache MINA SSHD Project</a> + * @see <a href="https://github.com/openssh/openssh-portable/blob/b5b405fee/PROTOCOL#L367">OpenSSH host key + * rotation</a> + */ +@FunctionalInterface +public interface NewHostKeysHandler { + + /** + * Invoked when new keys have been received and verified. + * <p> + * If this method updates the {@code known_hosts} file or other key database with the new keys, it is recommended to + * do so in a separate thread to avoid blocking the calling thread. + * </p> + * + * @param session the {@link ClientSession} that received the keys; can be used to figure out which server sent + * these keys (via the session's host config entry or server address) + * @param hostKeys the verified host keys received; never {@code null} and never containing {@code null}; may + * contain host certificates + */ + void receiveNewHostKeys(ClientSession session, Collection<PublicKey> hostKeys); +} diff --git a/sshd-core/src/main/java/org/apache/sshd/client/global/OpenSshHostKeysHandler.java b/sshd-core/src/main/java/org/apache/sshd/client/global/OpenSshHostKeysHandler.java index 817fdf7c3..c9db3c9d7 100644 --- a/sshd-core/src/main/java/org/apache/sshd/client/global/OpenSshHostKeysHandler.java +++ b/sshd-core/src/main/java/org/apache/sshd/client/global/OpenSshHostKeysHandler.java @@ -19,20 +19,35 @@ package org.apache.sshd.client.global; +import java.net.InetSocketAddress; +import java.net.SocketAddress; import java.security.PublicKey; +import java.util.ArrayList; import java.util.Collection; +import java.util.List; +import java.util.stream.Collectors; +import org.apache.sshd.client.session.ClientSession; +import org.apache.sshd.common.NamedFactory; +import org.apache.sshd.common.SshConstants; +import org.apache.sshd.common.config.keys.KeyUtils; +import org.apache.sshd.common.config.keys.OpenSshCertificate; +import org.apache.sshd.common.config.keys.UnsupportedSshPublicKey; import org.apache.sshd.common.global.AbstractOpenSshHostKeysHandler; +import org.apache.sshd.common.kex.KexProposalOption; +import org.apache.sshd.common.keyprovider.KeyPairProvider; import org.apache.sshd.common.session.Session; +import org.apache.sshd.common.signature.Signature; import org.apache.sshd.common.util.GenericUtils; import org.apache.sshd.common.util.ValidateUtils; import org.apache.sshd.common.util.buffer.Buffer; +import org.apache.sshd.common.util.buffer.BufferException; +import org.apache.sshd.common.util.buffer.ByteArrayBuffer; import org.apache.sshd.common.util.buffer.keys.BufferPublicKeyParser; +import org.apache.sshd.common.util.net.SshdSocketAddress; /** - * A handler for the "hostkeys...@openssh.com" request - for now, only reads the presented host key. One can - * override the {@link #handleHostKeys(Session, Collection, boolean, Buffer)} methods in order to do something with the - * keys + * A handler for the "hostkeys...@openssh.com" request. * * @author <a href="mailto:d...@mina.apache.org">Apache MINA SSHD Project</a> * @see <a href="https://github.com/openssh/openssh-portable/blob/master/PROTOCOL">OpenSSH protocol - section 2.5</a> @@ -52,8 +67,7 @@ public class OpenSshHostKeysHandler extends AbstractOpenSshHostKeysHandler { } @Override - protected Result handleHostKeys( - Session session, Collection<? extends PublicKey> keys, boolean wantReply, Buffer buffer) + protected Result handleHostKeys(Session session, Collection<PublicKey> keys, boolean wantReply, Buffer buffer) throws Exception { // according to the spec, no reply should be required ValidateUtils.checkTrue(!wantReply, "Unexpected reply required for the host keys of %s", session); @@ -61,7 +75,136 @@ public class OpenSshHostKeysHandler extends AbstractOpenSshHostKeysHandler { log.debug("handleHostKeys({})[want-reply={}] received {} keys", session, wantReply, GenericUtils.size(keys)); } - + // Build and send the signature request, then verify signatures. + ClientSession client = ValidateUtils.checkInstanceOf(session, ClientSession.class, + "handleHostKeys(%s) called on a ServerSession", session); + List<PublicKey> validKeys = keys.stream().filter(key -> { + if (key instanceof OpenSshCertificate) { + return isValidHostCertificate(client, (OpenSshCertificate) key); + } + return !(key instanceof UnsupportedSshPublicKey); + }).collect(Collectors.toList()); + Buffer request = session.createBuffer(SshConstants.SSH_MSG_GLOBAL_REQUEST); + request.putString(org.apache.sshd.server.global.OpenSshHostKeysHandler.REQUEST); + request.putBoolean(true); // want-reply + validKeys.forEach(request::putPublicKey); + client.request(request, org.apache.sshd.server.global.OpenSshHostKeysHandler.REQUEST, + (cmd, reply) -> { + if (cmd == SshConstants.SSH_MSG_REQUEST_SUCCESS) { + handleHostKeyRotation(client, validKeys, reply); + } + }); return Result.Replied; } + + protected void handleHostKeyRotation(ClientSession client, List<PublicKey> proposedKeys, Buffer reply) { + List<PublicKey> newKeys = new ArrayList<>(); + proposedKeys.forEach(k -> { + byte[] signature = reply.getBytes(); + String keyType = KeyUtils.getKeyType(k); + PublicKey signingKey = k; + if (k instanceof OpenSshCertificate) { + signingKey = ((OpenSshCertificate) k).getCertPubKey(); + } + String algo = KeyUtils.getKeyType(signingKey); + // RSA is special... + if (KeyPairProvider.SSH_RSA.equals(algo)) { + // If a RSA host key was negotiated in KEX, use the signature algorithm from there: + String negotiated = client.getKexNegotiationResult().get(KexProposalOption.ALGORITHMS); + String canonical = KeyUtils.getCanonicalKeyType(negotiated); + if (KeyPairProvider.SSH_RSA.equals(canonical) || KeyPairProvider.SSH_RSA_CERT.equals(canonical)) { + algo = KeyUtils.getSignatureAlgorithm(negotiated); + } else { + // Look at what kind of signature we have. We accept only RSA_SHA256/512 + Buffer sigBuf = new ByteArrayBuffer(signature); + try { + algo = sigBuf.getString(); + if (KeyPairProvider.SSH_RSA.equals(algo) + || !KeyPairProvider.SSH_RSA.equals(KeyUtils.getCanonicalKeyType(algo))) { + return; + } + } catch (BufferException e) { + if (log.isDebugEnabled()) { + log.debug("handleHostKeyRotation({}) ignoring {} key because signature data is invalid", client, + keyType); + } + return; + } + } + } + // Verify the signature. + Signature verifier = NamedFactory.create(client.getSignatureFactories(), algo); + if (verifier == null) { + if (log.isDebugEnabled()) { + log.debug("handleHostKeyRotation({}) ignoring {} key because no signature verifier for {}", client, keyType, + algo); + } + return; + } + Buffer expected = new ByteArrayBuffer(); + expected.putString(org.apache.sshd.server.global.OpenSshHostKeysHandler.REQUEST); + expected.putBytes(client.getSessionId()); + expected.putPublicKey(k); + byte[] data = expected.getCompactData(); + try { + verifier.initVerifier(client, signingKey); + verifier.update(client, data); + if (!verifier.verify(client, signature)) { + if (log.isDebugEnabled()) { + log.debug("handleHostKeyRotation({}) ignoring {} key because {} signature doesn't match", client, + keyType, algo); + } + return; + } + } catch (Exception e) { + if (log.isDebugEnabled()) { + log.debug("handleHostKeyRotation({}) ignoring {} key: exception during {} signature verification: {}", + client, keyType, algo, e.toString()); + } + return; + } + newKeys.add(k); + }); + if (reply.available() > 0) { + log.warn("handleHostKeyRotation({}) extra data of {} bytes ignored", client, reply.available()); + } + if (!newKeys.isEmpty()) { + client.getFactoryManager().getNewHostKeysHandler().receiveNewHostKeys(client, newKeys); + } + } + + private boolean isValidHostCertificate(ClientSession session, OpenSshCertificate cert) { + if (!OpenSshCertificate.Type.HOST.equals(cert.getType()) || !OpenSshCertificate.isValidNow(cert) + || !cert.getCriticalOptions().isEmpty()) { + return false; + } + try { + if (!OpenSshCertificate.verifySignature(cert, session.getSignatureFactories())) { + return false; + } + } catch (Exception e) { + if (log.isDebugEnabled()) { + log.debug("isValidHostCertificate({}) could not verify host ertificate signature; got {}", session, + e.toString()); + } + return false; + } + // Empty principals in a host certificate mean the certificate is valid for any host. + Collection<String> principals = cert.getPrincipals(); + if (!GenericUtils.isEmpty(principals)) { + /* + * We compare only the connect address against the principals and do not do any reverse DNS lookups. If one + * wants to connect with the IP it has to be included in the principals list of the certificate. + */ + SocketAddress connectSocketAddress = session.getConnectAddress(); + String hostName = null; + if (connectSocketAddress instanceof SshdSocketAddress) { + hostName = ((SshdSocketAddress) connectSocketAddress).getHostName(); + } else if (connectSocketAddress instanceof InetSocketAddress) { + hostName = ((InetSocketAddress) connectSocketAddress).getHostString(); + } + return hostName != null && principals.contains(hostName); + } + return true; + } } diff --git a/sshd-core/src/main/java/org/apache/sshd/client/kex/DHGClient.java b/sshd-core/src/main/java/org/apache/sshd/client/kex/DHGClient.java index bab1928f0..cc9bdbe7d 100644 --- a/sshd-core/src/main/java/org/apache/sshd/client/kex/DHGClient.java +++ b/sshd-core/src/main/java/org/apache/sshd/client/kex/DHGClient.java @@ -252,19 +252,7 @@ public class DHGClient extends AbstractDHClientKeyExchange { "KeyExchange signature verification failed, CA expired for key ID=" + keyId); } - String sigAlg = openSshKey.getSignatureAlgorithm(); - if (!keyAlg.equals(KeyUtils.getCanonicalKeyType(sigAlg))) { - throw new SshException(SshConstants.SSH2_DISCONNECT_KEY_EXCHANGE_FAILED, - "Found invalid signature alg " + sigAlg + " for key ID=" + keyId + " using a " + keyAlg + " CA key"); - } - - Signature verif = ValidateUtils.checkNotNull( - NamedFactory.create(session.getSignatureFactories(), sigAlg), - "No KeyExchange CA verifier located for algorithm=%s of key ID=%s", sigAlg, keyId); - verif.initVerifier(session, signatureKey); - verif.update(session, openSshKey.getMessage()); - - if (!verif.verify(session, openSshKey.getSignature())) { + if (!OpenSshCertificate.verifySignature(openSshKey, session.getSignatureFactories())) { throw new SshException(SshConstants.SSH2_DISCONNECT_KEY_EXCHANGE_FAILED, "KeyExchange CA signature verification failed for key type=" + keyAlg + " of key ID=" + keyId); } @@ -298,7 +286,7 @@ public class DHGClient extends AbstractDHClientKeyExchange { } } - if (!GenericUtils.isEmpty(openSshKey.getCriticalOptions())) { + if (!openSshKey.getCriticalOptions().isEmpty()) { // no critical option defined for host keys yet throw new SshException(SshConstants.SSH2_DISCONNECT_KEY_EXCHANGE_FAILED, "KeyExchange signature verification failed, unrecognized critical options " diff --git a/sshd-core/src/main/java/org/apache/sshd/client/session/AbstractClientSession.java b/sshd-core/src/main/java/org/apache/sshd/client/session/AbstractClientSession.java index 38225cd12..8ff043a81 100644 --- a/sshd-core/src/main/java/org/apache/sshd/client/session/AbstractClientSession.java +++ b/sshd-core/src/main/java/org/apache/sshd/client/session/AbstractClientSession.java @@ -25,11 +25,13 @@ import java.nio.charset.Charset; import java.nio.charset.StandardCharsets; import java.security.KeyPair; import java.security.PublicKey; -import java.util.HashSet; +import java.util.Collection; +import java.util.Collections; import java.util.List; import java.util.Map; import java.util.Objects; import java.util.Set; +import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.CopyOnWriteArrayList; import org.apache.sshd.client.ClientFactoryManager; @@ -95,7 +97,7 @@ public abstract class AbstractClientSession extends AbstractSession implements C // Keep a record of already known and accepted host keys for this session. This enables us to bypass the // ServerKeyVerifier if a re-KEX returned the same key, or even a different key it had announced before via the // OpenSSH "hostkeys...@openssh.com" SSH_MSG_GLOBAL_REQUEST. - private final Set<String> sessionHostKeys = new HashSet<>(); + private final Set<String> sessionHostKeys = ConcurrentHashMap.newKeySet(); private final List<Object> identities = new CopyOnWriteArrayList<>(); private final AuthenticationIdentitiesProvider identitiesProvider; private final AttributeRepository connectionContext; @@ -177,6 +179,53 @@ public abstract class AbstractClientSession extends AbstractSession implements C this.serverKey = serverKey; } + @Override + public void registerHostKey(PublicKey hostKey) { + if (hostKey instanceof OpenSshCertificate) { + OpenSshCertificate cert = (OpenSshCertificate) hostKey; + ValidateUtils.checkTrue(OpenSshCertificate.Type.HOST.equals(cert.getType()), "Invalid certificate type"); + sessionHostKeys.add("@cert-authority " + PublicKeyEntry.toString(cert.getCaPubKey())); + } else { + ValidateUtils.checkNotNull(hostKey, "Null host key cannot be registered"); + sessionHostKeys.add(PublicKeyEntry.toString(hostKey)); + } + } + + @Override + public Collection<String> getRegisteredHostKeys() { + return Collections.unmodifiableSet(sessionHostKeys); + } + + @Override + protected void checkKeys() throws IOException { + PublicKey hostKey = Objects.requireNonNull(getServerKey(), "No server key to verify"); + String serializedKey; + if (hostKey instanceof OpenSshCertificate) { + // Expiration etc. has already been checked. + serializedKey = "@cert-authority " + PublicKeyEntry.toString(((OpenSshCertificate) hostKey).getCaPubKey()); + } else { + serializedKey = PublicKeyEntry.toString(hostKey); + } + if (!sessionHostKeys.contains(serializedKey)) { + ServerKeyVerifier verifier = Objects.requireNonNull(getServerKeyVerifier(), "No server key verifier"); + IoSession networkSession = getIoSession(); + SocketAddress remoteAddress = networkSession.getRemoteAddress(); + SshdSocketAddress targetServerAddress = getAttribute(ClientSessionCreator.TARGET_SERVER); + if (targetServerAddress != null) { + remoteAddress = targetServerAddress.toInetSocketAddress(); + } + boolean verified = verifier.verifyServerKey(this, remoteAddress, hostKey); + if (log.isDebugEnabled()) { + log.debug("checkKeys({}) key={}-{}, verified={}", this, KeyUtils.getKeyType(hostKey), + KeyUtils.getFingerPrint(hostKey), verified); + } + if (!verified) { + throw new SshException(SshConstants.SSH2_DISCONNECT_HOST_KEY_NOT_VERIFIABLE, "Server key did not validate"); + } + sessionHostKeys.add(serializedKey); + } + } + @Override public ServerKeyVerifier getServerKeyVerifier() { ClientFactoryManager manager = getFactoryManager(); @@ -528,36 +577,6 @@ public abstract class AbstractClientSession extends AbstractSession implements C } } - @Override - protected void checkKeys() throws IOException { - PublicKey hostKey = Objects.requireNonNull(getServerKey(), "No server key to verify"); - String serializedKey; - if (hostKey instanceof OpenSshCertificate) { - // Expiration etc. has already been checked. - serializedKey = "cert-authority " + PublicKeyEntry.toString(((OpenSshCertificate) hostKey).getCaPubKey()); - } else { - serializedKey = PublicKeyEntry.toString(hostKey); - } - if (!sessionHostKeys.contains(serializedKey)) { - ServerKeyVerifier verifier = Objects.requireNonNull(getServerKeyVerifier(), "No server key verifier"); - IoSession networkSession = getIoSession(); - SocketAddress remoteAddress = networkSession.getRemoteAddress(); - SshdSocketAddress targetServerAddress = getAttribute(ClientSessionCreator.TARGET_SERVER); - if (targetServerAddress != null) { - remoteAddress = targetServerAddress.toInetSocketAddress(); - } - boolean verified = verifier.verifyServerKey(this, remoteAddress, hostKey); - if (log.isDebugEnabled()) { - log.debug("checkKeys({}) key={}-{}, verified={}", this, KeyUtils.getKeyType(hostKey), - KeyUtils.getFingerPrint(hostKey), verified); - } - if (!verified) { - throw new SshException(SshConstants.SSH2_DISCONNECT_HOST_KEY_NOT_VERIFIABLE, "Server key did not validate"); - } - sessionHostKeys.add(serializedKey); - } - } - @Override public KeyExchangeFuture switchToNoneCipher() throws IOException { Service service = currentService.getService(); diff --git a/sshd-core/src/main/java/org/apache/sshd/client/session/ClientSession.java b/sshd-core/src/main/java/org/apache/sshd/client/session/ClientSession.java index d649309d8..7e2f027f8 100644 --- a/sshd-core/src/main/java/org/apache/sshd/client/session/ClientSession.java +++ b/sshd-core/src/main/java/org/apache/sshd/client/session/ClientSession.java @@ -49,6 +49,7 @@ import org.apache.sshd.client.channel.ClientChannel; import org.apache.sshd.client.channel.ClientChannelEvent; import org.apache.sshd.client.config.hosts.HostConfigEntry; import org.apache.sshd.client.future.AuthFuture; +import org.apache.sshd.client.keyverifier.ServerKeyVerifier; import org.apache.sshd.client.session.forward.DynamicPortForwardingTracker; import org.apache.sshd.client.session.forward.ExplicitPortForwardingTracker; import org.apache.sshd.common.AttributeRepository; @@ -132,12 +133,47 @@ public interface ClientSession AuthFuture auth() throws IOException; /** - * Retrieves the server's key + * Retrieves the server's host key: the one it presented in the last key exchange. * - * @return The server's {@link PublicKey} - {@code null} if KEX not started or not completed + * @return the server's {@link PublicKey}; may be {@code null} if no key exchange has been completed yet */ PublicKey getServerKey(); + /** + * Explicitly registers a known host key for the session. Intended to be used if the server announces additional + * host keys as part of the OpenSSH "hostkeys...@openssh.com" extension, but it can also be used to register any + * host key at any other time. + * <p> + * If the server presents a host key that matches any of the ones registered in the session, the key will be + * accepted implicitly without consulting the {@link ServerKeyVerifier}. + * </p> + * <p> + * If the {@code hostKey} is a host certificate, the CA key will be registered. When the server presents a + * certificate, the certificate will be checked in any case and will be rejected if it is invalid or expired. If it + * is valid and not expired, it will be accepted implicitly if its CA key is already registered. + * </p> + * + * @param hostKey the host key to register + * @throws IllegalArgumentException if {@code hostKey} is a user certificate + */ + void registerHostKey(PublicKey hostKey); + + /** + * Obtains a read-only collection of all currently registered server host keys is OpenSSH format, i.e. key type as a + * string, followed by the base64-encoded key, prefixed with "@cert-authority" if it's a CA key. + * <p> + * For a normal key: "key-type AAAA..."; for a host certificate CA key "@cert-authority key-type AAAA...". + * </p> + * <p> + * Note that this is not <em>quite</em> the format used in a OpenSSH {@code known_hosts} file as there are no host + * patterns. + * </p> + * + * @return all currently registered server host keys; never {@code null} but possibly empty, and never containing + * {@code null} + */ + Collection<String> getRegisteredHostKeys(); + /** * Create a channel of the given type. Same as calling <code>createChannel(type, null)</code>. * diff --git a/sshd-core/src/main/java/org/apache/sshd/common/global/AbstractOpenSshHostKeysHandler.java b/sshd-core/src/main/java/org/apache/sshd/common/global/AbstractOpenSshHostKeysHandler.java index 60c25675d..f994ea87b 100644 --- a/sshd-core/src/main/java/org/apache/sshd/common/global/AbstractOpenSshHostKeysHandler.java +++ b/sshd-core/src/main/java/org/apache/sshd/common/global/AbstractOpenSshHostKeysHandler.java @@ -106,7 +106,7 @@ public abstract class AbstractOpenSshHostKeysHandler extends AbstractConnectionS } protected abstract Result handleHostKeys( - Session session, Collection<? extends PublicKey> keys, boolean wantReply, Buffer buffer) + Session session, Collection<PublicKey> keys, boolean wantReply, Buffer buffer) throws Exception; @Override diff --git a/sshd-core/src/main/java/org/apache/sshd/server/auth/pubkey/UserAuthPublicKey.java b/sshd-core/src/main/java/org/apache/sshd/server/auth/pubkey/UserAuthPublicKey.java index d16534373..2195f2de9 100644 --- a/sshd-core/src/main/java/org/apache/sshd/server/auth/pubkey/UserAuthPublicKey.java +++ b/sshd-core/src/main/java/org/apache/sshd/server/auth/pubkey/UserAuthPublicKey.java @@ -182,28 +182,14 @@ public class UserAuthPublicKey extends AbstractUserAuth implements SignatureFact } protected void verifyCertificateSignature(ServerSession session, OpenSshCertificate cert) throws Exception { - PublicKey signatureKey = cert.getCaPubKey(); - String keyAlg = KeyUtils.getKeyType(signatureKey); - String keyId = cert.getId(); - - String sigAlg = cert.getSignatureAlgorithm(); - if (!keyAlg.equals(KeyUtils.getCanonicalKeyType(sigAlg))) { + if (!OpenSshCertificate.verifySignature(cert, session.getSignatureFactories())) { throw new CertificateException( - "Found invalid signature alg " + sigAlg + " for key ID=" + keyId + " using a " + keyAlg + " CA key"); - } - - Signature verif = ValidateUtils.checkNotNull(NamedFactory.create(session.getSignatureFactories(), sigAlg), - "No CA verifier located for algorithm=%s of key ID=%s", sigAlg, keyId); - verif.initVerifier(session, signatureKey); - verif.update(session, cert.getMessage()); - - if (!verif.verify(session, cert.getSignature())) { - throw new CertificateException("CA signature verification failed for key type=" + keyAlg + " of key ID=" + keyId); + "CA signature verification failed for key type=" + cert.getKeyType() + " of key ID=" + cert.getId()); } } protected void verifyCertificateSources(ServerSession session, OpenSshCertificate cert) throws CertificateException { - String allowedSources = cert.getCriticalOptionsMap().get("source-address"); + String allowedSources = cert.getCriticalOptions().get("source-address"); if (allowedSources == null) { return; } diff --git a/sshd-core/src/main/java/org/apache/sshd/server/global/OpenSshHostKeysHandler.java b/sshd-core/src/main/java/org/apache/sshd/server/global/OpenSshHostKeysHandler.java index 628c6b883..d7d79bd20 100644 --- a/sshd-core/src/main/java/org/apache/sshd/server/global/OpenSshHostKeysHandler.java +++ b/sshd-core/src/main/java/org/apache/sshd/server/global/OpenSshHostKeysHandler.java @@ -30,7 +30,9 @@ import org.apache.sshd.common.NamedResource; import org.apache.sshd.common.RuntimeSshException; import org.apache.sshd.common.SshConstants; import org.apache.sshd.common.config.keys.KeyUtils; +import org.apache.sshd.common.config.keys.OpenSshCertificate; import org.apache.sshd.common.global.AbstractOpenSshHostKeysHandler; +import org.apache.sshd.common.kex.KexProposalOption; import org.apache.sshd.common.keyprovider.KeyPairProvider; import org.apache.sshd.common.session.Session; import org.apache.sshd.common.signature.Signature; @@ -86,7 +88,7 @@ public class OpenSshHostKeysHandler extends AbstractOpenSshHostKeysHandler imple @Override protected Result handleHostKeys( - Session session, Collection<? extends PublicKey> keys, boolean wantReply, Buffer buffer) + Session session, Collection<PublicKey> keys, boolean wantReply, Buffer buffer) throws Exception { // according to the specification there MUST be reply required by the server ValidateUtils.checkTrue(wantReply, "No reply required for host keys of %s", session); @@ -107,11 +109,26 @@ public class OpenSshHostKeysHandler extends AbstractOpenSshHostKeysHandler imple KeyPairProvider kpp = Objects.requireNonNull( ((ServerSession) session).getKeyPairProvider(), "No server keys provider"); for (PublicKey k : keys) { - String keyType = KeyUtils.getKeyType(k); + PublicKey signingKey = k; + if (k instanceof OpenSshCertificate) { + signingKey = ((OpenSshCertificate) k).getCertPubKey(); + } + String keyType = KeyUtils.getKeyType(signingKey); + String algo = keyType; + // RSA is special... + if (KeyPairProvider.SSH_RSA.equals(algo)) { + // If a RSA host key was negotiated in KEX, use the signature algorithm from there: + String negotiated = session.getKexNegotiationResult().get(KexProposalOption.ALGORITHMS); + String canonical = KeyUtils.getCanonicalKeyType(negotiated); + if (KeyPairProvider.SSH_RSA.equals(canonical) || KeyPairProvider.SSH_RSA_CERT.equals(canonical)) { + algo = KeyUtils.getSignatureAlgorithm(negotiated); + } else { + algo = KeyUtils.RSA_SHA512_KEY_TYPE_ALIAS; + } + } + Signature verifier = ValidateUtils.checkNotNull( - NamedFactory.create(factories, keyType), - "No signer could be located for key type=%s", - keyType); + NamedFactory.create(factories, algo), "No signer could be located for key type=%s, algo=%s", keyType, algo); KeyPair kp; try { diff --git a/sshd-core/src/test/java/org/apache/sshd/certificates/GenerateOpenSSHClientCertificateTest.java b/sshd-core/src/test/java/org/apache/sshd/certificates/GenerateOpenSSHClientCertificateTest.java index fc8e58ba8..9f37cc06c 100644 --- a/sshd-core/src/test/java/org/apache/sshd/certificates/GenerateOpenSSHClientCertificateTest.java +++ b/sshd-core/src/test/java/org/apache/sshd/certificates/GenerateOpenSSHClientCertificateTest.java @@ -26,6 +26,8 @@ import java.security.KeyPair; import java.security.PublicKey; import java.util.Arrays; import java.util.Collections; +import java.util.HashMap; +import java.util.Map; import org.apache.sshd.certificate.OpenSshCertificateBuilder; import org.apache.sshd.common.BaseBuilder; @@ -137,22 +139,23 @@ public class GenerateOpenSSHClientCertificateTest extends BaseTestSupport { if (i > 0) { signatureAlgorithm = "rsa-sha2-" + caName.substring(i + 5); } + Map<String, String> options = new HashMap<>(); + options.put("force-command", "/path/to/script.sh"); + options.put("source-address", "127.0.0.1/32"); + options.put("verify-required", null); + Map<String, String> extensions = new HashMap<>(); + extensions.put("no-touch-required", null); + extensions.put("permit-X11-forwarding", null); + extensions.put("permit-agent-forwarding", null); + extensions.put("permit-port-forwarding", null); + extensions.put("permit-pty", null); + extensions.put("permit-user-rc", null); final OpenSshCertificate signedCert = OpenSshCertificateBuilder.userCertificate() .serial(0L) .publicKey(clientPublicKey) .id("user01") .principals(Collections.singletonList("user01")) - .criticalOptions(Arrays.asList( - new OpenSshCertificate.CertificateOption("force-command", "/path/to/script.sh"), - new OpenSshCertificate.CertificateOption("source-address", "127.0.0.1/32"), - new OpenSshCertificate.CertificateOption("verify-required"))) - .extensions(Arrays.asList( - new OpenSshCertificate.CertificateOption("no-touch-required"), - new OpenSshCertificate.CertificateOption("permit-X11-forwarding"), - new OpenSshCertificate.CertificateOption("permit-agent-forwarding"), - new OpenSshCertificate.CertificateOption("permit-port-forwarding"), - new OpenSshCertificate.CertificateOption("permit-pty"), - new OpenSshCertificate.CertificateOption("permit-user-rc"))) + .criticalOptions(options).extensions(extensions) .sign(caKeypair, signatureAlgorithm); // encode to ssh public key format diff --git a/sshd-core/src/test/java/org/apache/sshd/certificates/GenerateOpenSshClientCertificateOracleTest.java b/sshd-core/src/test/java/org/apache/sshd/certificates/GenerateOpenSshClientCertificateOracleTest.java index 71d2b5b7e..0cd105ac2 100644 --- a/sshd-core/src/test/java/org/apache/sshd/certificates/GenerateOpenSshClientCertificateOracleTest.java +++ b/sshd-core/src/test/java/org/apache/sshd/certificates/GenerateOpenSshClientCertificateOracleTest.java @@ -25,6 +25,8 @@ import java.security.KeyPair; import java.security.PublicKey; import java.util.Arrays; import java.util.Collections; +import java.util.HashMap; +import java.util.Map; import org.apache.sshd.certificate.OpenSshCertificateBuilder; import org.apache.sshd.common.BaseBuilder; @@ -114,21 +116,22 @@ class GenerateOpenSshClientCertificateOracleTest extends BaseTestSupport { final KeyPair caKeypair = keyPairProvider.loadKeys(null).iterator().next(); + Map<String, String> options = new HashMap<>(); + options.put("source-address", "127.0.0.1/32"); + options.put("force-command", "/path/to/script.sh"); + Map<String, String> extensions = new HashMap<>(); + extensions.put("permit-pty", null); + extensions.put("permit-agent-forwarding", null); + extensions.put("permit-user-rc", null); + extensions.put("permit-port-forwarding", null); + extensions.put("permit-X11-forwarding", null); final OpenSshCertificate signedCert = OpenSshCertificateBuilder.userCertificate() .serial(0L) .publicKey(clientPublicKey) .id("user01") .principals(Collections.singletonList("user01")) .nonce(oracle.getNonce()) - .criticalOptions(Arrays.asList( - new OpenSshCertificate.CertificateOption("source-address", "127.0.0.1/32"), - new OpenSshCertificate.CertificateOption("force-command", "/path/to/script.sh"))) - .extensions(Arrays.asList( - new OpenSshCertificate.CertificateOption("permit-pty"), - new OpenSshCertificate.CertificateOption("permit-agent-forwarding"), - new OpenSshCertificate.CertificateOption("permit-user-rc"), - new OpenSshCertificate.CertificateOption("permit-port-forwarding"), - new OpenSshCertificate.CertificateOption("permit-X11-forwarding"))) + .criticalOptions(options).extensions(extensions) .sign(caKeypair, params.algorithm); // Check that they both validate diff --git a/sshd-core/src/test/java/org/apache/sshd/certificates/OpenSSHCertificateParserTest.java b/sshd-core/src/test/java/org/apache/sshd/certificates/OpenSSHCertificateParserTest.java index 988513339..149778562 100644 --- a/sshd-core/src/test/java/org/apache/sshd/certificates/OpenSSHCertificateParserTest.java +++ b/sshd-core/src/test/java/org/apache/sshd/certificates/OpenSSHCertificateParserTest.java @@ -87,13 +87,8 @@ class OpenSSHCertificateParserTest extends BaseTestSupport { assertEquals(OpenSshCertificate.INFINITY, typedCert.getValidBefore()); // forever assertTrue(typedCert.getCriticalOptions().isEmpty()); assertEquals( - Arrays.asList( - new OpenSshCertificate.CertificateOption("permit-X11-forwarding", ""), - new OpenSshCertificate.CertificateOption("permit-agent-forwarding", ""), - new OpenSshCertificate.CertificateOption("permit-port-forwarding", ""), - new OpenSshCertificate.CertificateOption("permit-pty", ""), - new OpenSshCertificate.CertificateOption("permit-user-rc", "")), - typedCert.getExtensions()); + "{permit-X11-forwarding=, permit-agent-forwarding=, permit-port-forwarding=, permit-pty=, permit-user-rc=}", + typedCert.getExtensions().toString()); assertEquals(params.sigAlgorithm, typedCert.getSignatureAlgorithm()); verifySignature(typedCert); Buffer buffer = new ByteArrayBuffer(); diff --git a/sshd-core/src/test/java/org/apache/sshd/client/auth/pubkey/HostBoundPubKeyAuthTest.java b/sshd-core/src/test/java/org/apache/sshd/client/auth/pubkey/HostBoundPubKeyAuthTest.java index 9f91609aa..7cda5f037 100644 --- a/sshd-core/src/test/java/org/apache/sshd/client/auth/pubkey/HostBoundPubKeyAuthTest.java +++ b/sshd-core/src/test/java/org/apache/sshd/client/auth/pubkey/HostBoundPubKeyAuthTest.java @@ -18,12 +18,18 @@ */ package org.apache.sshd.client.auth.pubkey; +import java.security.PublicKey; import java.util.Arrays; +import java.util.Collection; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; import java.util.regex.Matcher; import java.util.regex.Pattern; import org.apache.sshd.client.SshClient; +import org.apache.sshd.client.config.DefaultNewHostKeysHandler; import org.apache.sshd.client.session.ClientSession; +import org.apache.sshd.common.config.keys.PublicKeyEntry; import org.apache.sshd.common.kex.extension.DefaultClientKexExtensionHandler; import org.apache.sshd.common.keyprovider.FileKeyPairProvider; import org.apache.sshd.util.test.BaseTestSupport; @@ -104,19 +110,38 @@ public class HostBoundPubKeyAuthTest extends BaseTestSupport { @MethodSource("privateKeyParams") @ParameterizedTest(name = "{0}") - public void pubkeyAuth(String privateKeyName) throws Exception { + void pubkeyAuth(String privateKeyName) throws Exception { initHostBoundPubKeyAuthTest(privateKeyName); FileKeyPairProvider keyPairProvider = CommonTestSupportUtils.createTestKeyPairProvider(getPrivateKeyResource()); SshClient client = setupTestClient(); client.setKeyIdentityProvider(keyPairProvider); - client.start(); + CountDownLatch haveHostKeys = new CountDownLatch(1); + client.setNewHostKeysHandler(new DefaultNewHostKeysHandler() { + @Override + public void receiveNewHostKeys(ClientSession session, Collection<PublicKey> hostKeys) { + super.receiveNewHostKeys(session, hostKeys); + haveHostKeys.countDown(); + } + }); + client.start(); Integer actualPort = sshdContainer.getMappedPort(22); String actualHost = sshdContainer.getHost(); try (ClientSession session = client.connect("bob", actualHost, actualPort).verify(CONNECT_TIMEOUT).getSession()) { session.auth().verify(AUTH_TIMEOUT); assertEquals(Integer.valueOf(0), session.getAttribute(DefaultClientKexExtensionHandler.HOSTBOUND_AUTHENTICATION)); checkLog(sshdContainer.getLogs()); + // We know that this OpenSSH generates three server host keys, and it does send the + // "hostkeys...@openssh.com" global request. + assertTrue(haveHostKeys.await(CONNECT_TIMEOUT.toMillis(), TimeUnit.MILLISECONDS)); + Collection<String> knownHostKeys = session.getRegisteredHostKeys(); + assertNotNull(knownHostKeys); + knownHostKeys.forEach(s -> LOG.info("Known host key " + s)); + assertEquals(3, knownHostKeys.size()); + PublicKey hostKey = session.getServerKey(); + assertNotNull(hostKey); + String serialized = PublicKeyEntry.toString(hostKey); + assertTrue(knownHostKeys.contains(serialized)); } finally { client.stop(); } diff --git a/sshd-core/src/test/java/org/apache/sshd/common/global/OpenSshHostKeysHandlerTest.java b/sshd-core/src/test/java/org/apache/sshd/common/global/OpenSshHostKeysHandlerTest.java index 4e16801af..e71b8f1db 100644 --- a/sshd-core/src/test/java/org/apache/sshd/common/global/OpenSshHostKeysHandlerTest.java +++ b/sshd-core/src/test/java/org/apache/sshd/common/global/OpenSshHostKeysHandlerTest.java @@ -74,7 +74,7 @@ class OpenSshHostKeysHandlerTest extends BaseTestSupport { = new org.apache.sshd.client.global.OpenSshHostKeysHandler() { @Override protected Result handleHostKeys( - Session session, Collection<? extends PublicKey> keys, boolean wantReply, + Session session, Collection<PublicKey> keys, boolean wantReply, Buffer buffer) throws Exception { handlerCalled[0] = true; assertEquals(2, keys.size(), "Unexpected number of keys"); @@ -95,7 +95,7 @@ class OpenSshHostKeysHandlerTest extends BaseTestSupport { = new org.apache.sshd.server.global.OpenSshHostKeysHandler() { @Override protected Result handleHostKeys( - Session session, Collection<? extends PublicKey> keys, boolean wantReply, + Session session, Collection<PublicKey> keys, boolean wantReply, Buffer buffer) throws Exception { handlerCalled[0] = true; return Result.Replied; diff --git a/sshd-core/src/test/java/org/apache/sshd/common/session/GlobalRequestTest.java b/sshd-core/src/test/java/org/apache/sshd/common/session/GlobalRequestTest.java index 9733e469b..80bd423c6 100644 --- a/sshd-core/src/test/java/org/apache/sshd/common/session/GlobalRequestTest.java +++ b/sshd-core/src/test/java/org/apache/sshd/common/session/GlobalRequestTest.java @@ -268,9 +268,7 @@ class GlobalRequestTest extends BaseTestSupport { client.setGlobalRequestHandlers(Collections.singletonList(new OpenSshHostKeysHandler() { @Override - protected Result handleHostKeys( - Session session, Collection<? extends PublicKey> keys, boolean wantReply, - Buffer buffer) + protected Result handleHostKeys(Session session, Collection<PublicKey> keys, boolean wantReply, Buffer buffer) throws Exception { ValidateUtils.checkTrue(!wantReply, "Unexpected reply required for the host keys of %s", session); assertFalse(GenericUtils.isEmpty(keys)); @@ -369,9 +367,7 @@ class GlobalRequestTest extends BaseTestSupport { client.setGlobalRequestHandlers(Collections.singletonList(new OpenSshHostKeysHandler() { @Override - protected Result handleHostKeys( - Session session, Collection<? extends PublicKey> keys, boolean wantReply, - Buffer buffer) + protected Result handleHostKeys(Session session, Collection<PublicKey> keys, boolean wantReply, Buffer buffer) throws Exception { ValidateUtils.checkTrue(!wantReply, "Unexpected reply required for the host keys of %s", session); assertFalse(GenericUtils.isEmpty(keys));