diff --git a/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/components/ssh.json b/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/components/ssh.json
index 48513dab7dff8..dd1c9ac5740bf 100644
--- a/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/components/ssh.json
+++ b/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/components/ssh.json
@@ -47,11 +47,11 @@
"certResource": { "index": 19, "kind": "property", "displayName": "Cert Resource", "group": "security", "label": "security", "required": false, "type": "string", "javaType": "java.lang.String", "deprecated": false, "autowired": false, "secret": false, "configurationClass": "org.apache.camel.component.ssh.SshConfiguration", "configurationField": "configuration", "description": "Sets the resource path of the certificate to use for Authentication. Will use ResourceHelperKeyPairProvider to resolve file based certificate, and depends on keyType setting." },
"certResourcePassword": { "index": 20, "kind": "property", "displayName": "Cert Resource Password", "group": "security", "label": "security", "required": false, "type": "string", "javaType": "java.lang.String", "deprecated": false, "autowired": false, "secret": true, "security": "secret", "configurationClass": "org.apache.camel.component.ssh.SshConfiguration", "configurationField": "configuration", "description": "Sets the password to use in loading certResource, if certResource is an encrypted key." },
"ciphers": { "index": 21, "kind": "property", "displayName": "Ciphers", "group": "security", "label": "security", "required": false, "type": "string", "javaType": "java.lang.String", "deprecated": false, "autowired": false, "secret": false, "configurationClass": "org.apache.camel.component.ssh.SshConfiguration", "configurationField": "configuration", "description": "Comma-separated list of allowed\/supported ciphers in their order of preference." },
- "failOnUnknownHost": { "index": 22, "kind": "property", "displayName": "Fail On Unknown Host", "group": "security", "label": "security", "required": false, "type": "boolean", "javaType": "boolean", "deprecated": false, "autowired": false, "secret": false, "security": "insecure:ssl", "insecureValue": "false", "defaultValue": false, "configurationClass": "org.apache.camel.component.ssh.SshConfiguration", "configurationField": "configuration", "description": "Specifies whether a connection to an unknown SSH server host key should fail. When false, host keys that are not present in the known_hosts resource are accepted." },
+ "failOnUnknownHost": { "index": 22, "kind": "property", "displayName": "Fail On Unknown Host", "group": "security", "label": "security", "required": false, "type": "boolean", "javaType": "boolean", "deprecated": false, "autowired": false, "secret": false, "security": "insecure:ssl", "insecureValue": "false", "defaultValue": false, "configurationClass": "org.apache.camel.component.ssh.SshConfiguration", "configurationField": "configuration", "description": "Specifies whether a connection to a server whose host has no entry without a marker in the known_hosts resource should fail. When false, such a server is accepted. A server whose host has entries without a marker is always rejected when it presents a key that none of them lists, and a key listed in a matching revoked entry is always rejected." },
"kex": { "index": 23, "kind": "property", "displayName": "Kex", "group": "security", "label": "security", "required": false, "type": "string", "javaType": "java.lang.String", "deprecated": false, "autowired": false, "secret": false, "configurationClass": "org.apache.camel.component.ssh.SshConfiguration", "configurationField": "configuration", "description": "Comma-separated list of allowed\/supported key exchange algorithms in their order of preference." },
"keyPairProvider": { "index": 24, "kind": "property", "displayName": "Key Pair Provider", "group": "security", "label": "security", "required": false, "type": "object", "javaType": "org.apache.sshd.common.keyprovider.KeyPairProvider", "deprecated": false, "autowired": false, "secret": false, "configurationClass": "org.apache.camel.component.ssh.SshConfiguration", "configurationField": "configuration", "description": "Sets the KeyPairProvider reference to use when connecting using Certificates to the remote SSH Server." },
"keyType": { "index": 25, "kind": "property", "displayName": "Key Type", "group": "security", "label": "security", "required": false, "type": "string", "javaType": "java.lang.String", "deprecated": false, "autowired": false, "secret": false, "configurationClass": "org.apache.camel.component.ssh.SshConfiguration", "configurationField": "configuration", "description": "Sets the key type to pass to the KeyPairProvider as part of authentication. KeyPairProvider.loadKey(...) will be passed this value. From Camel 3.0.0 \/ 2.25.0, by default Camel will select the first available KeyPair that is loaded. Prior to this, a KeyType of 'ssh-rsa' was enforced by default." },
- "knownHostsResource": { "index": 26, "kind": "property", "displayName": "Known Hosts Resource", "group": "security", "label": "security", "required": false, "type": "string", "javaType": "java.lang.String", "deprecated": false, "autowired": false, "secret": false, "security": "insecure:ssl", "configurationClass": "org.apache.camel.component.ssh.SshConfiguration", "configurationField": "configuration", "description": "Sets the resource path for a known_hosts file used to verify the SSH server host key. When not set, the client does not verify the server host key against a known_hosts file." },
+ "knownHostsResource": { "index": 26, "kind": "property", "displayName": "Known Hosts Resource", "group": "security", "label": "security", "required": false, "type": "string", "javaType": "java.lang.String", "deprecated": false, "autowired": false, "secret": false, "security": "insecure:ssl", "configurationClass": "org.apache.camel.component.ssh.SshConfiguration", "configurationField": "configuration", "description": "Sets the resource path for a known_hosts file used to verify the SSH server host key. The file uses the OpenSSH known_hosts format, and its entries are matched against the host and port configured on the endpoint. A resource that cannot be read, or that has a line that cannot be read, makes every connection fail. When not set, the client does not verify the server host key against a known_hosts file." },
"macs": { "index": 27, "kind": "property", "displayName": "Macs", "group": "security", "label": "security", "required": false, "type": "string", "javaType": "java.lang.String", "deprecated": false, "autowired": false, "secret": false, "configurationClass": "org.apache.camel.component.ssh.SshConfiguration", "configurationField": "configuration", "description": "Comma-separated list of allowed\/supported message authentication code algorithms in their order of preference. The MAC algorithm is used for data integrity protection." },
"password": { "index": 28, "kind": "property", "displayName": "Password", "group": "security", "label": "security", "required": false, "type": "string", "javaType": "java.lang.String", "deprecated": false, "autowired": false, "secret": true, "security": "secret", "configurationClass": "org.apache.camel.component.ssh.SshConfiguration", "configurationField": "configuration", "description": "Sets the password to use in connecting to remote SSH server. Requires keyPairProvider to be set to null." },
"signatures": { "index": 29, "kind": "property", "displayName": "Signatures", "group": "security", "label": "security", "required": false, "type": "string", "javaType": "java.lang.String", "deprecated": false, "autowired": false, "secret": false, "configurationClass": "org.apache.camel.component.ssh.SshConfiguration", "configurationField": "configuration", "description": "Comma-separated list of allowed\/supported signature algorithms in their order of preference." },
@@ -102,11 +102,11 @@
"certResource": { "index": 35, "kind": "parameter", "displayName": "Cert Resource", "group": "security", "label": "security", "required": false, "type": "string", "javaType": "java.lang.String", "deprecated": false, "autowired": false, "secret": false, "configurationClass": "org.apache.camel.component.ssh.SshConfiguration", "configurationField": "configuration", "description": "Sets the resource path of the certificate to use for Authentication. Will use ResourceHelperKeyPairProvider to resolve file based certificate, and depends on keyType setting." },
"certResourcePassword": { "index": 36, "kind": "parameter", "displayName": "Cert Resource Password", "group": "security", "label": "security", "required": false, "type": "string", "javaType": "java.lang.String", "deprecated": false, "autowired": false, "secret": true, "security": "secret", "configurationClass": "org.apache.camel.component.ssh.SshConfiguration", "configurationField": "configuration", "description": "Sets the password to use in loading certResource, if certResource is an encrypted key." },
"ciphers": { "index": 37, "kind": "parameter", "displayName": "Ciphers", "group": "security", "label": "security", "required": false, "type": "string", "javaType": "java.lang.String", "deprecated": false, "autowired": false, "secret": false, "configurationClass": "org.apache.camel.component.ssh.SshConfiguration", "configurationField": "configuration", "description": "Comma-separated list of allowed\/supported ciphers in their order of preference." },
- "failOnUnknownHost": { "index": 38, "kind": "parameter", "displayName": "Fail On Unknown Host", "group": "security", "label": "security", "required": false, "type": "boolean", "javaType": "boolean", "deprecated": false, "autowired": false, "secret": false, "security": "insecure:ssl", "insecureValue": "false", "defaultValue": false, "configurationClass": "org.apache.camel.component.ssh.SshConfiguration", "configurationField": "configuration", "description": "Specifies whether a connection to an unknown SSH server host key should fail. When false, host keys that are not present in the known_hosts resource are accepted." },
+ "failOnUnknownHost": { "index": 38, "kind": "parameter", "displayName": "Fail On Unknown Host", "group": "security", "label": "security", "required": false, "type": "boolean", "javaType": "boolean", "deprecated": false, "autowired": false, "secret": false, "security": "insecure:ssl", "insecureValue": "false", "defaultValue": false, "configurationClass": "org.apache.camel.component.ssh.SshConfiguration", "configurationField": "configuration", "description": "Specifies whether a connection to a server whose host has no entry without a marker in the known_hosts resource should fail. When false, such a server is accepted. A server whose host has entries without a marker is always rejected when it presents a key that none of them lists, and a key listed in a matching revoked entry is always rejected." },
"kex": { "index": 39, "kind": "parameter", "displayName": "Kex", "group": "security", "label": "security", "required": false, "type": "string", "javaType": "java.lang.String", "deprecated": false, "autowired": false, "secret": false, "configurationClass": "org.apache.camel.component.ssh.SshConfiguration", "configurationField": "configuration", "description": "Comma-separated list of allowed\/supported key exchange algorithms in their order of preference." },
"keyPairProvider": { "index": 40, "kind": "parameter", "displayName": "Key Pair Provider", "group": "security", "label": "security", "required": false, "type": "object", "javaType": "org.apache.sshd.common.keyprovider.KeyPairProvider", "deprecated": false, "autowired": false, "secret": false, "configurationClass": "org.apache.camel.component.ssh.SshConfiguration", "configurationField": "configuration", "description": "Sets the KeyPairProvider reference to use when connecting using Certificates to the remote SSH Server." },
"keyType": { "index": 41, "kind": "parameter", "displayName": "Key Type", "group": "security", "label": "security", "required": false, "type": "string", "javaType": "java.lang.String", "deprecated": false, "autowired": false, "secret": false, "configurationClass": "org.apache.camel.component.ssh.SshConfiguration", "configurationField": "configuration", "description": "Sets the key type to pass to the KeyPairProvider as part of authentication. KeyPairProvider.loadKey(...) will be passed this value. From Camel 3.0.0 \/ 2.25.0, by default Camel will select the first available KeyPair that is loaded. Prior to this, a KeyType of 'ssh-rsa' was enforced by default." },
- "knownHostsResource": { "index": 42, "kind": "parameter", "displayName": "Known Hosts Resource", "group": "security", "label": "security", "required": false, "type": "string", "javaType": "java.lang.String", "deprecated": false, "autowired": false, "secret": false, "security": "insecure:ssl", "configurationClass": "org.apache.camel.component.ssh.SshConfiguration", "configurationField": "configuration", "description": "Sets the resource path for a known_hosts file used to verify the SSH server host key. When not set, the client does not verify the server host key against a known_hosts file." },
+ "knownHostsResource": { "index": 42, "kind": "parameter", "displayName": "Known Hosts Resource", "group": "security", "label": "security", "required": false, "type": "string", "javaType": "java.lang.String", "deprecated": false, "autowired": false, "secret": false, "security": "insecure:ssl", "configurationClass": "org.apache.camel.component.ssh.SshConfiguration", "configurationField": "configuration", "description": "Sets the resource path for a known_hosts file used to verify the SSH server host key. The file uses the OpenSSH known_hosts format, and its entries are matched against the host and port configured on the endpoint. A resource that cannot be read, or that has a line that cannot be read, makes every connection fail. When not set, the client does not verify the server host key against a known_hosts file." },
"macs": { "index": 43, "kind": "parameter", "displayName": "Macs", "group": "security", "label": "security", "required": false, "type": "string", "javaType": "java.lang.String", "deprecated": false, "autowired": false, "secret": false, "configurationClass": "org.apache.camel.component.ssh.SshConfiguration", "configurationField": "configuration", "description": "Comma-separated list of allowed\/supported message authentication code algorithms in their order of preference. The MAC algorithm is used for data integrity protection." },
"password": { "index": 44, "kind": "parameter", "displayName": "Password", "group": "security", "label": "security", "required": false, "type": "string", "javaType": "java.lang.String", "deprecated": false, "autowired": false, "secret": true, "security": "secret", "configurationClass": "org.apache.camel.component.ssh.SshConfiguration", "configurationField": "configuration", "description": "Sets the password to use in connecting to remote SSH server. Requires keyPairProvider to be set to null." },
"signatures": { "index": 45, "kind": "parameter", "displayName": "Signatures", "group": "security", "label": "security", "required": false, "type": "string", "javaType": "java.lang.String", "deprecated": false, "autowired": false, "secret": false, "configurationClass": "org.apache.camel.component.ssh.SshConfiguration", "configurationField": "configuration", "description": "Comma-separated list of allowed\/supported signature algorithms in their order of preference." },
diff --git a/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/docs/ssh-component.adoc b/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/docs/ssh-component.adoc
index 698ec5f305daa..e1b2e38de453a 100644
--- a/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/docs/ssh-component.adoc
+++ b/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/docs/ssh-component.adoc
@@ -163,6 +163,72 @@ YAML::
An example of using Public Key authentication is provided in
`examples/camel-example-ssh-security`.
+=== Server host key verification
+
+The SSH Component verifies the host key of the SSH server only when the `knownHostsResource` option
+is set. It refers to a file in the OpenSSH `known_hosts` format, such as the output of `ssh-keyscan`.
+Set `failOnUnknownHost` to `true` as well, so that a server without any entry in that file is rejected.
+When it is `false` (the default), such a server is accepted and a warning is logged.
+
+The entries are matched as OpenSSH matches them:
+
+* Each host pattern of an entry is compared, ignoring case, with the host name or address the client
+connects to: the host configured on the endpoint, or the `HostName` that `~/.ssh/config` gives for it.
+It is written `host` for port 22 and `[host]:port` for another port. `*` and `?` are wildcards, a
+pattern starting with `!` excludes the hosts it matches, and hashed entries are supported. So
+`db.example.com` only applies to port 22, `[db.example.com]:2222` to port 2222, and `*` to every host
+and port. The host is never resolved: an endpoint configured with an address only uses the entries for
+that address, and an IPv6 address such as `::1` is not expanded.
+* The server is accepted when an entry of its host lists the key it presents. A host can be listed
+with several keys, one per line.
+* The server is rejected when the key it presents is listed in a matching `@revoked` entry, or when
+its host has entries without a marker but none of them lists that key, whatever the value of
+`failOnUnknownHost`. That option only decides for a host without such an entry: `@revoked` and
+`@cert-authority` entries do not list a host.
+* `@cert-authority` entries (OpenSSH host certificates) are not supported and are ignored.
+* A `known_hosts` resource that cannot be read, or that has a line that cannot be read (for example
+an unknown `@` marker, a missing key, or a key that is not valid base64), makes every connection fail.
+
+The following route verifies the host key of a server listening on port 2222 against a
+`known_hosts` file that contains the line `[db.example.com]:2222 ssh-ed25519 AAAAC3NzaC1lZDI1NTE5...`.
+
+[tabs]
+====
+Java::
++
+[source,java]
+----
+from("direct:status")
+ .to("ssh://scott@db.example.com:2222?certResource=classpath:test_rsa&knownHostsResource=file:/etc/camel/known_hosts&failOnUnknownHost=true");
+----
+
+XML::
++
+[source,xml]
+----
+
+
+
+
+----
+
+YAML::
++
+[source,yaml]
+----
+- route:
+ from:
+ uri: direct:status
+ steps:
+ - to:
+ uri: ssh://scott@db.example.com:2222
+ parameters:
+ certResource: "classpath:test_rsa"
+ knownHostsResource: "file:/etc/camel/known_hosts"
+ failOnUnknownHost: true
+----
+====
+
[[SSH-CertificateDependencies]]
=== Certificate Dependencies
diff --git a/components/camel-ssh/src/generated/resources/META-INF/org/apache/camel/component/ssh/ssh.json b/components/camel-ssh/src/generated/resources/META-INF/org/apache/camel/component/ssh/ssh.json
index 48513dab7dff8..dd1c9ac5740bf 100644
--- a/components/camel-ssh/src/generated/resources/META-INF/org/apache/camel/component/ssh/ssh.json
+++ b/components/camel-ssh/src/generated/resources/META-INF/org/apache/camel/component/ssh/ssh.json
@@ -47,11 +47,11 @@
"certResource": { "index": 19, "kind": "property", "displayName": "Cert Resource", "group": "security", "label": "security", "required": false, "type": "string", "javaType": "java.lang.String", "deprecated": false, "autowired": false, "secret": false, "configurationClass": "org.apache.camel.component.ssh.SshConfiguration", "configurationField": "configuration", "description": "Sets the resource path of the certificate to use for Authentication. Will use ResourceHelperKeyPairProvider to resolve file based certificate, and depends on keyType setting." },
"certResourcePassword": { "index": 20, "kind": "property", "displayName": "Cert Resource Password", "group": "security", "label": "security", "required": false, "type": "string", "javaType": "java.lang.String", "deprecated": false, "autowired": false, "secret": true, "security": "secret", "configurationClass": "org.apache.camel.component.ssh.SshConfiguration", "configurationField": "configuration", "description": "Sets the password to use in loading certResource, if certResource is an encrypted key." },
"ciphers": { "index": 21, "kind": "property", "displayName": "Ciphers", "group": "security", "label": "security", "required": false, "type": "string", "javaType": "java.lang.String", "deprecated": false, "autowired": false, "secret": false, "configurationClass": "org.apache.camel.component.ssh.SshConfiguration", "configurationField": "configuration", "description": "Comma-separated list of allowed\/supported ciphers in their order of preference." },
- "failOnUnknownHost": { "index": 22, "kind": "property", "displayName": "Fail On Unknown Host", "group": "security", "label": "security", "required": false, "type": "boolean", "javaType": "boolean", "deprecated": false, "autowired": false, "secret": false, "security": "insecure:ssl", "insecureValue": "false", "defaultValue": false, "configurationClass": "org.apache.camel.component.ssh.SshConfiguration", "configurationField": "configuration", "description": "Specifies whether a connection to an unknown SSH server host key should fail. When false, host keys that are not present in the known_hosts resource are accepted." },
+ "failOnUnknownHost": { "index": 22, "kind": "property", "displayName": "Fail On Unknown Host", "group": "security", "label": "security", "required": false, "type": "boolean", "javaType": "boolean", "deprecated": false, "autowired": false, "secret": false, "security": "insecure:ssl", "insecureValue": "false", "defaultValue": false, "configurationClass": "org.apache.camel.component.ssh.SshConfiguration", "configurationField": "configuration", "description": "Specifies whether a connection to a server whose host has no entry without a marker in the known_hosts resource should fail. When false, such a server is accepted. A server whose host has entries without a marker is always rejected when it presents a key that none of them lists, and a key listed in a matching revoked entry is always rejected." },
"kex": { "index": 23, "kind": "property", "displayName": "Kex", "group": "security", "label": "security", "required": false, "type": "string", "javaType": "java.lang.String", "deprecated": false, "autowired": false, "secret": false, "configurationClass": "org.apache.camel.component.ssh.SshConfiguration", "configurationField": "configuration", "description": "Comma-separated list of allowed\/supported key exchange algorithms in their order of preference." },
"keyPairProvider": { "index": 24, "kind": "property", "displayName": "Key Pair Provider", "group": "security", "label": "security", "required": false, "type": "object", "javaType": "org.apache.sshd.common.keyprovider.KeyPairProvider", "deprecated": false, "autowired": false, "secret": false, "configurationClass": "org.apache.camel.component.ssh.SshConfiguration", "configurationField": "configuration", "description": "Sets the KeyPairProvider reference to use when connecting using Certificates to the remote SSH Server." },
"keyType": { "index": 25, "kind": "property", "displayName": "Key Type", "group": "security", "label": "security", "required": false, "type": "string", "javaType": "java.lang.String", "deprecated": false, "autowired": false, "secret": false, "configurationClass": "org.apache.camel.component.ssh.SshConfiguration", "configurationField": "configuration", "description": "Sets the key type to pass to the KeyPairProvider as part of authentication. KeyPairProvider.loadKey(...) will be passed this value. From Camel 3.0.0 \/ 2.25.0, by default Camel will select the first available KeyPair that is loaded. Prior to this, a KeyType of 'ssh-rsa' was enforced by default." },
- "knownHostsResource": { "index": 26, "kind": "property", "displayName": "Known Hosts Resource", "group": "security", "label": "security", "required": false, "type": "string", "javaType": "java.lang.String", "deprecated": false, "autowired": false, "secret": false, "security": "insecure:ssl", "configurationClass": "org.apache.camel.component.ssh.SshConfiguration", "configurationField": "configuration", "description": "Sets the resource path for a known_hosts file used to verify the SSH server host key. When not set, the client does not verify the server host key against a known_hosts file." },
+ "knownHostsResource": { "index": 26, "kind": "property", "displayName": "Known Hosts Resource", "group": "security", "label": "security", "required": false, "type": "string", "javaType": "java.lang.String", "deprecated": false, "autowired": false, "secret": false, "security": "insecure:ssl", "configurationClass": "org.apache.camel.component.ssh.SshConfiguration", "configurationField": "configuration", "description": "Sets the resource path for a known_hosts file used to verify the SSH server host key. The file uses the OpenSSH known_hosts format, and its entries are matched against the host and port configured on the endpoint. A resource that cannot be read, or that has a line that cannot be read, makes every connection fail. When not set, the client does not verify the server host key against a known_hosts file." },
"macs": { "index": 27, "kind": "property", "displayName": "Macs", "group": "security", "label": "security", "required": false, "type": "string", "javaType": "java.lang.String", "deprecated": false, "autowired": false, "secret": false, "configurationClass": "org.apache.camel.component.ssh.SshConfiguration", "configurationField": "configuration", "description": "Comma-separated list of allowed\/supported message authentication code algorithms in their order of preference. The MAC algorithm is used for data integrity protection." },
"password": { "index": 28, "kind": "property", "displayName": "Password", "group": "security", "label": "security", "required": false, "type": "string", "javaType": "java.lang.String", "deprecated": false, "autowired": false, "secret": true, "security": "secret", "configurationClass": "org.apache.camel.component.ssh.SshConfiguration", "configurationField": "configuration", "description": "Sets the password to use in connecting to remote SSH server. Requires keyPairProvider to be set to null." },
"signatures": { "index": 29, "kind": "property", "displayName": "Signatures", "group": "security", "label": "security", "required": false, "type": "string", "javaType": "java.lang.String", "deprecated": false, "autowired": false, "secret": false, "configurationClass": "org.apache.camel.component.ssh.SshConfiguration", "configurationField": "configuration", "description": "Comma-separated list of allowed\/supported signature algorithms in their order of preference." },
@@ -102,11 +102,11 @@
"certResource": { "index": 35, "kind": "parameter", "displayName": "Cert Resource", "group": "security", "label": "security", "required": false, "type": "string", "javaType": "java.lang.String", "deprecated": false, "autowired": false, "secret": false, "configurationClass": "org.apache.camel.component.ssh.SshConfiguration", "configurationField": "configuration", "description": "Sets the resource path of the certificate to use for Authentication. Will use ResourceHelperKeyPairProvider to resolve file based certificate, and depends on keyType setting." },
"certResourcePassword": { "index": 36, "kind": "parameter", "displayName": "Cert Resource Password", "group": "security", "label": "security", "required": false, "type": "string", "javaType": "java.lang.String", "deprecated": false, "autowired": false, "secret": true, "security": "secret", "configurationClass": "org.apache.camel.component.ssh.SshConfiguration", "configurationField": "configuration", "description": "Sets the password to use in loading certResource, if certResource is an encrypted key." },
"ciphers": { "index": 37, "kind": "parameter", "displayName": "Ciphers", "group": "security", "label": "security", "required": false, "type": "string", "javaType": "java.lang.String", "deprecated": false, "autowired": false, "secret": false, "configurationClass": "org.apache.camel.component.ssh.SshConfiguration", "configurationField": "configuration", "description": "Comma-separated list of allowed\/supported ciphers in their order of preference." },
- "failOnUnknownHost": { "index": 38, "kind": "parameter", "displayName": "Fail On Unknown Host", "group": "security", "label": "security", "required": false, "type": "boolean", "javaType": "boolean", "deprecated": false, "autowired": false, "secret": false, "security": "insecure:ssl", "insecureValue": "false", "defaultValue": false, "configurationClass": "org.apache.camel.component.ssh.SshConfiguration", "configurationField": "configuration", "description": "Specifies whether a connection to an unknown SSH server host key should fail. When false, host keys that are not present in the known_hosts resource are accepted." },
+ "failOnUnknownHost": { "index": 38, "kind": "parameter", "displayName": "Fail On Unknown Host", "group": "security", "label": "security", "required": false, "type": "boolean", "javaType": "boolean", "deprecated": false, "autowired": false, "secret": false, "security": "insecure:ssl", "insecureValue": "false", "defaultValue": false, "configurationClass": "org.apache.camel.component.ssh.SshConfiguration", "configurationField": "configuration", "description": "Specifies whether a connection to a server whose host has no entry without a marker in the known_hosts resource should fail. When false, such a server is accepted. A server whose host has entries without a marker is always rejected when it presents a key that none of them lists, and a key listed in a matching revoked entry is always rejected." },
"kex": { "index": 39, "kind": "parameter", "displayName": "Kex", "group": "security", "label": "security", "required": false, "type": "string", "javaType": "java.lang.String", "deprecated": false, "autowired": false, "secret": false, "configurationClass": "org.apache.camel.component.ssh.SshConfiguration", "configurationField": "configuration", "description": "Comma-separated list of allowed\/supported key exchange algorithms in their order of preference." },
"keyPairProvider": { "index": 40, "kind": "parameter", "displayName": "Key Pair Provider", "group": "security", "label": "security", "required": false, "type": "object", "javaType": "org.apache.sshd.common.keyprovider.KeyPairProvider", "deprecated": false, "autowired": false, "secret": false, "configurationClass": "org.apache.camel.component.ssh.SshConfiguration", "configurationField": "configuration", "description": "Sets the KeyPairProvider reference to use when connecting using Certificates to the remote SSH Server." },
"keyType": { "index": 41, "kind": "parameter", "displayName": "Key Type", "group": "security", "label": "security", "required": false, "type": "string", "javaType": "java.lang.String", "deprecated": false, "autowired": false, "secret": false, "configurationClass": "org.apache.camel.component.ssh.SshConfiguration", "configurationField": "configuration", "description": "Sets the key type to pass to the KeyPairProvider as part of authentication. KeyPairProvider.loadKey(...) will be passed this value. From Camel 3.0.0 \/ 2.25.0, by default Camel will select the first available KeyPair that is loaded. Prior to this, a KeyType of 'ssh-rsa' was enforced by default." },
- "knownHostsResource": { "index": 42, "kind": "parameter", "displayName": "Known Hosts Resource", "group": "security", "label": "security", "required": false, "type": "string", "javaType": "java.lang.String", "deprecated": false, "autowired": false, "secret": false, "security": "insecure:ssl", "configurationClass": "org.apache.camel.component.ssh.SshConfiguration", "configurationField": "configuration", "description": "Sets the resource path for a known_hosts file used to verify the SSH server host key. When not set, the client does not verify the server host key against a known_hosts file." },
+ "knownHostsResource": { "index": 42, "kind": "parameter", "displayName": "Known Hosts Resource", "group": "security", "label": "security", "required": false, "type": "string", "javaType": "java.lang.String", "deprecated": false, "autowired": false, "secret": false, "security": "insecure:ssl", "configurationClass": "org.apache.camel.component.ssh.SshConfiguration", "configurationField": "configuration", "description": "Sets the resource path for a known_hosts file used to verify the SSH server host key. The file uses the OpenSSH known_hosts format, and its entries are matched against the host and port configured on the endpoint. A resource that cannot be read, or that has a line that cannot be read, makes every connection fail. When not set, the client does not verify the server host key against a known_hosts file." },
"macs": { "index": 43, "kind": "parameter", "displayName": "Macs", "group": "security", "label": "security", "required": false, "type": "string", "javaType": "java.lang.String", "deprecated": false, "autowired": false, "secret": false, "configurationClass": "org.apache.camel.component.ssh.SshConfiguration", "configurationField": "configuration", "description": "Comma-separated list of allowed\/supported message authentication code algorithms in their order of preference. The MAC algorithm is used for data integrity protection." },
"password": { "index": 44, "kind": "parameter", "displayName": "Password", "group": "security", "label": "security", "required": false, "type": "string", "javaType": "java.lang.String", "deprecated": false, "autowired": false, "secret": true, "security": "secret", "configurationClass": "org.apache.camel.component.ssh.SshConfiguration", "configurationField": "configuration", "description": "Sets the password to use in connecting to remote SSH server. Requires keyPairProvider to be set to null." },
"signatures": { "index": 45, "kind": "parameter", "displayName": "Signatures", "group": "security", "label": "security", "required": false, "type": "string", "javaType": "java.lang.String", "deprecated": false, "autowired": false, "secret": false, "configurationClass": "org.apache.camel.component.ssh.SshConfiguration", "configurationField": "configuration", "description": "Comma-separated list of allowed\/supported signature algorithms in their order of preference." },
diff --git a/components/camel-ssh/src/main/docs/ssh-component.adoc b/components/camel-ssh/src/main/docs/ssh-component.adoc
index 698ec5f305daa..e1b2e38de453a 100644
--- a/components/camel-ssh/src/main/docs/ssh-component.adoc
+++ b/components/camel-ssh/src/main/docs/ssh-component.adoc
@@ -163,6 +163,72 @@ YAML::
An example of using Public Key authentication is provided in
`examples/camel-example-ssh-security`.
+=== Server host key verification
+
+The SSH Component verifies the host key of the SSH server only when the `knownHostsResource` option
+is set. It refers to a file in the OpenSSH `known_hosts` format, such as the output of `ssh-keyscan`.
+Set `failOnUnknownHost` to `true` as well, so that a server without any entry in that file is rejected.
+When it is `false` (the default), such a server is accepted and a warning is logged.
+
+The entries are matched as OpenSSH matches them:
+
+* Each host pattern of an entry is compared, ignoring case, with the host name or address the client
+connects to: the host configured on the endpoint, or the `HostName` that `~/.ssh/config` gives for it.
+It is written `host` for port 22 and `[host]:port` for another port. `*` and `?` are wildcards, a
+pattern starting with `!` excludes the hosts it matches, and hashed entries are supported. So
+`db.example.com` only applies to port 22, `[db.example.com]:2222` to port 2222, and `*` to every host
+and port. The host is never resolved: an endpoint configured with an address only uses the entries for
+that address, and an IPv6 address such as `::1` is not expanded.
+* The server is accepted when an entry of its host lists the key it presents. A host can be listed
+with several keys, one per line.
+* The server is rejected when the key it presents is listed in a matching `@revoked` entry, or when
+its host has entries without a marker but none of them lists that key, whatever the value of
+`failOnUnknownHost`. That option only decides for a host without such an entry: `@revoked` and
+`@cert-authority` entries do not list a host.
+* `@cert-authority` entries (OpenSSH host certificates) are not supported and are ignored.
+* A `known_hosts` resource that cannot be read, or that has a line that cannot be read (for example
+an unknown `@` marker, a missing key, or a key that is not valid base64), makes every connection fail.
+
+The following route verifies the host key of a server listening on port 2222 against a
+`known_hosts` file that contains the line `[db.example.com]:2222 ssh-ed25519 AAAAC3NzaC1lZDI1NTE5...`.
+
+[tabs]
+====
+Java::
++
+[source,java]
+----
+from("direct:status")
+ .to("ssh://scott@db.example.com:2222?certResource=classpath:test_rsa&knownHostsResource=file:/etc/camel/known_hosts&failOnUnknownHost=true");
+----
+
+XML::
++
+[source,xml]
+----
+
+
+
+
+----
+
+YAML::
++
+[source,yaml]
+----
+- route:
+ from:
+ uri: direct:status
+ steps:
+ - to:
+ uri: ssh://scott@db.example.com:2222
+ parameters:
+ certResource: "classpath:test_rsa"
+ knownHostsResource: "file:/etc/camel/known_hosts"
+ failOnUnknownHost: true
+----
+====
+
[[SSH-CertificateDependencies]]
=== Certificate Dependencies
diff --git a/components/camel-ssh/src/main/java/org/apache/camel/component/ssh/ResourceBasedSSHKeyVerifier.java b/components/camel-ssh/src/main/java/org/apache/camel/component/ssh/ResourceBasedSSHKeyVerifier.java
index 1b5556466d72e..73bd032342bb1 100644
--- a/components/camel-ssh/src/main/java/org/apache/camel/component/ssh/ResourceBasedSSHKeyVerifier.java
+++ b/components/camel-ssh/src/main/java/org/apache/camel/component/ssh/ResourceBasedSSHKeyVerifier.java
@@ -18,30 +18,66 @@
import java.io.IOException;
import java.io.InputStream;
-import java.math.BigInteger;
import java.net.InetSocketAddress;
import java.net.SocketAddress;
-import java.security.NoSuchAlgorithmException;
+import java.nio.charset.StandardCharsets;
+import java.security.GeneralSecurityException;
+import java.security.MessageDigest;
import java.security.PublicKey;
-import java.security.spec.InvalidKeySpecException;
import java.util.Base64;
-import java.util.LinkedList;
-import java.util.List;
+import java.util.Collections;
+import java.util.Locale;
+
+import javax.crypto.Mac;
+import javax.crypto.spec.SecretKeySpec;
import org.apache.camel.CamelContext;
import org.apache.camel.support.ResourceHelper;
-import org.apache.camel.util.IOHelper;
+import org.apache.sshd.client.config.hosts.HostConfigEntry;
import org.apache.sshd.client.keyverifier.ServerKeyVerifier;
import org.apache.sshd.client.session.ClientSession;
-import org.bouncycastle.util.Arrays;
+import org.apache.sshd.client.session.ClientSessionCreator;
+import org.apache.sshd.common.AttributeRepository;
+import org.apache.sshd.common.config.keys.KeyUtils;
+import org.apache.sshd.common.config.keys.PublicKeyEntry;
+import org.apache.sshd.common.config.keys.PublicKeyEntryResolver;
+import org.apache.sshd.common.session.SessionContext;
+import org.apache.sshd.common.util.net.SshdSocketAddress;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
/**
* ServerKeyVerifier that takes a camel resource as input file to validate the server key against.
- *
+ *
+ * The resource uses the OpenSSH known_hosts format, and its entries are matched as OpenSSH matches them. Each host
+ * pattern of an entry is compared, ignoring case, with the host name or address the client connects to (the host
+ * configured on the endpoint, or the {@code HostName} that {@code ~/.ssh/config} gives for it, as it is written and
+ * never resolved through DNS, so an IPv6 address such as {@code ::1} is not expanded), written {@code host} for port 22
+ * and {@code [host]:port} for another port: {@code *} and {@code ?} are wildcards, a pattern starting with {@code !}
+ * excludes the hosts it matches, and hashed entries are supported. The server is accepted when an entry of its host
+ * lists the key it presents. It is rejected when that key is listed in a matching {@code @revoked} entry, or when its
+ * host has entries without a marker but none of them lists that key. {@code failOnUnknownHost} only decides for a host
+ * without such an entry: {@code @revoked} and {@code @cert-authority} entries do not list a host, and host certificates
+ * are not supported. A resource that cannot be read, or that has a line that cannot be read, rejects every server.
*/
public class ResourceBasedSSHKeyVerifier implements ServerKeyVerifier {
+ private static final int DEFAULT_PORT = 22;
+ private static final String MARKER_REVOKED = "@revoked";
+ private static final String MARKER_CERT_AUTHORITY = "@cert-authority";
+ private static final String HASHED_HOST_PREFIX = "|1|";
+ private static final String HASH_ALGORITHM = "HmacSHA1";
+ private static final int HASH_LENGTH = 20;
+
+ /**
+ * The host configuration that the client connected with, which the ssh producer and consumer put in the connection
+ * context of the session: the entries are matched against its {@code HostName} as it is written. Without it, the
+ * address the client connected to is used, which writes an IPv6 address such as {@code ::1} in its full form.
+ *
+ * @since 4.24
+ */
+ public static final AttributeRepository.AttributeKey HOST_CONFIG_ENTRY
+ = new AttributeRepository.AttributeKey<>();
+
protected final Logger log = LoggerFactory.getLogger(getClass());
private CamelContext camelContext;
@@ -62,21 +98,58 @@ public ResourceBasedSSHKeyVerifier(CamelContext camelContext, String knownHostsR
@Override
public boolean verifyServerKey(ClientSession sshClientSession, SocketAddress remoteAddress, PublicKey serverKey) {
log.debug("Trying to find known_hosts file {}", knownHostsResource);
- InputStream knownHostsInputStream = null;
- try {
- knownHostsInputStream = ResourceHelper.resolveMandatoryResourceAsInputStream(camelContext,
- knownHostsResource);
- List possibleTokens = getKnownHostsFileTokensForSocketAddress(remoteAddress);
- log.debug("Trying to match PublicKey against provided known_hosts file");
- PublicKey matchingKey = findKeyForServerToken(knownHostsInputStream, possibleTokens);
- if (matchingKey != null) {
- log.debug("Found PublicKey match for server");
- return Arrays.areEqual(matchingKey.getEncoded(), serverKey.getEncoded());
- }
+ String knownHosts;
+ try (InputStream knownHostsInputStream
+ = ResourceHelper.resolveMandatoryResourceAsInputStream(camelContext, knownHostsResource)) {
+ knownHosts = new String(knownHostsInputStream.readAllBytes(), StandardCharsets.UTF_8);
} catch (IOException ioException) {
- log.debug(String.format("Could not find known_hosts file %s", knownHostsResource), ioException);
- } finally {
- IOHelper.close(knownHostsInputStream);
+ log.warn("Could not read the known_hosts file {}, connection will fail", knownHostsResource, ioException);
+ return false;
+ }
+
+ String server = toKnownHostsName(sshClientSession, remoteAddress);
+ boolean hostListed = false;
+ boolean keyListed = false;
+ int lineNumber = 0;
+ for (String line : knownHosts.split("\\R")) {
+ lineNumber++;
+ String text = line.trim();
+ if (text.isEmpty() || text.charAt(0) == '#') {
+ continue;
+ }
+ // a line that cannot be read could list the server, so it cannot be skipped
+ KnownHostsEntry entry = KnownHostsEntry.parse(text, sshClientSession);
+ if (entry == null) {
+ log.warn("Line {} of the known_hosts file {} cannot be read, connection will fail", lineNumber,
+ knownHostsResource);
+ return false;
+ }
+ if (server == null || MARKER_CERT_AUTHORITY.equals(entry.marker) || !entry.matches(server)) {
+ continue;
+ }
+ boolean sameKey = isSameKey(entry, serverKey);
+ if (MARKER_REVOKED.equals(entry.marker)) {
+ if (sameKey) {
+ log.warn("The key presented by {} is marked as revoked in the known_hosts file {}", server,
+ knownHostsResource);
+ return false;
+ }
+ } else {
+ hostListed = true;
+ if (sameKey) {
+ keyListed = true;
+ }
+ }
+ }
+
+ if (keyListed) {
+ log.debug("Found PublicKey match for server {}", server);
+ return true;
+ }
+ if (hostListed) {
+ log.warn("The key presented by {} does not match any key listed for it in the known_hosts file {}", server,
+ knownHostsResource);
+ return false;
}
if (failOnUnknownHost) {
log.warn("Could not find matching key for client session, connection will fail due to configuration");
@@ -88,105 +161,164 @@ public boolean verifyServerKey(ClientSession sshClientSession, SocketAddress rem
}
}
- private PublicKey findKeyForServerToken(InputStream knownHostsInputStream, List possibleTokens) {
- String knowHostsLines = camelContext.getTypeConverter().convertTo(String.class, knownHostsInputStream);
- if (knowHostsLines == null) {
- log.warn("Could not read from the known_hosts file input stream");
- return null;
+ private boolean isSameKey(KnownHostsEntry entry, PublicKey serverKey) {
+ if (entry.key == null) {
+ log.warn("Unsupported key type {} in the known_hosts file {}", entry.keyType, knownHostsResource);
+ return false;
}
+ return KeyUtils.compareKeys(entry.key, serverKey);
+ }
- for (String s : knowHostsLines.split("\n")) {
- String[] parts = s.split(" ");
- if (parts.length != 3) {
- log.warn("Found malformed entry in known_hosts file");
- continue;
- }
- String entry = parts[0];
- String key = parts[2];
- for (String serverToken : possibleTokens) {
- if (entry.contains(serverToken)) {
- try {
- return loadKey(key);
- } catch (NoSuchAlgorithmException | InvalidKeySpecException e) {
- log.warn(String.format("Could not load key for server token %s", entry), e);
- }
- }
- }
+ /*
+ * The name OpenSSH looks the server up by: the host name or address the client connected to, as the host
+ * configuration writes it and never resolved through DNS, with the port the client connected to, written host for
+ * port 22 and [host]:port for another port. The connected address cannot give the host as it is written, as it
+ * writes an IPv6 address such as ::1 in its full form.
+ */
+ private static String toKnownHostsName(ClientSession session, SocketAddress remoteAddress) {
+ if (!(remoteAddress instanceof InetSocketAddress) && !(remoteAddress instanceof SshdSocketAddress)) {
+ return null;
+ }
+ SshdSocketAddress address = SshdSocketAddress.toSshdSocketAddress(remoteAddress);
+ String name = address.getHostName();
+ SshdSocketAddress target = session != null ? session.getAttribute(ClientSessionCreator.TARGET_SERVER) : null;
+ AttributeRepository context = session != null ? session.getConnectionContext() : null;
+ HostConfigEntry hostConfig = context != null ? context.getAttribute(HOST_CONFIG_ENTRY) : null;
+ if (target != null) {
+ // a host reached through a jump host, whose HostName SSHD keeps as it is written
+ name = target.getHostName();
+ } else if (hostConfig != null && (hostConfig.getProxyJump() == null || hostConfig.getProxyJump().isEmpty())) {
+ // the host the client connected to directly, with the host configuration it connected with
+ name = hostConfig.getHostName();
}
- return null;
+ if (name.length() > 1 && name.charAt(0) == '[' && name.charAt(name.length() - 1) == ']') {
+ // an IPv6 address in brackets
+ name = name.substring(1, name.length() - 1);
+ }
+ name = name.toLowerCase(Locale.ROOT);
+ return address.getPort() == DEFAULT_PORT ? name : "[" + name + "]:" + address.getPort();
}
- private List getKnownHostsFileTokensForSocketAddress(SocketAddress remoteAddress) {
- List returnList = new LinkedList<>();
- if (remoteAddress instanceof InetSocketAddress) {
- InetSocketAddress inetSocketAddress = (InetSocketAddress) remoteAddress;
-
- String hostName = inetSocketAddress.getHostName();
- String ipAddress = inetSocketAddress.getAddress().getHostAddress();
- String remotePort = String.valueOf(inetSocketAddress.getPort());
-
- returnList.add(hostName);
- returnList.add("[" + hostName + "]:" + remotePort);
- returnList.add(ipAddress);
- returnList.add("[" + ipAddress + "]:" + remotePort);
+ /*
+ * Matches a pattern where * stands for any sequence of characters and ? for any single character.
+ */
+ static boolean matchesPattern(String pattern, String name) {
+ int p = 0;
+ int n = 0;
+ int star = -1;
+ int resume = 0;
+ while (n < name.length()) {
+ if (p < pattern.length() && (pattern.charAt(p) == '?' || pattern.charAt(p) == name.charAt(n))) {
+ p++;
+ n++;
+ } else if (p < pattern.length() && pattern.charAt(p) == '*') {
+ star = p++;
+ resume = n;
+ } else if (star >= 0) {
+ p = star + 1;
+ n = ++resume;
+ } else {
+ return false;
+ }
}
-
- return returnList;
+ while (p < pattern.length() && pattern.charAt(p) == '*') {
+ p++;
+ }
+ return p == pattern.length();
}
/*
- * Decode the public key string, which is a base64 encoded string that consists
- * of multiple parts: 1. public key type (ssh-rsa, ssh-dss, ...) 2. binary key
- * data (May consists of multiple parts)
- *
- * Each part is composed by two sub-parts 1. Length of the part (4 bytes) 2.
- * Binary part (length as defined by 1.)
- *
- * Uses SSHPublicKeyHolder to construct the actual PublicKey Object
- *
- * Note: Currently only supports RSA and DSA Public keys as required by
- * https://tools.ietf.org/html/rfc4253#section-6.6
- *
+ * A line of the known_hosts file: an optional marker, the host patterns or a hashed host, and the key. Anything
+ * after the key is a comment.
*/
- PublicKey loadKey(String key) throws NoSuchAlgorithmException, InvalidKeySpecException {
- SSHPublicKeyHolder sshPublicKeyHolder = new SSHPublicKeyHolder();
-
- byte[] keyByteArray = Base64.getDecoder().decode(key);
- int keyByteArrayCursor = 0;
-
- byte[] tmpData = new byte[4];
- int tmpCursor = 0;
-
- boolean getLengthMode = true;
- while (keyByteArrayCursor < keyByteArray.length) {
- if (getLengthMode) {
- if (tmpCursor < 4) {
- tmpData[tmpCursor] = keyByteArray[keyByteArrayCursor];
- tmpCursor++;
- keyByteArrayCursor++;
- continue;
- } else {
- tmpCursor = 0;
- getLengthMode = false;
- tmpData = new byte[byteArrayToInt(tmpData)];
+ private static final class KnownHostsEntry {
+ private final String marker;
+ private final String hosts;
+ private final byte[] salt;
+ private final byte[] hash;
+ private final String keyType;
+ private final PublicKey key;
+
+ private KnownHostsEntry(String marker, String hosts, byte[] salt, byte[] hash, String keyType, PublicKey key) {
+ this.marker = marker;
+ this.hosts = hosts;
+ this.salt = salt;
+ this.hash = hash;
+ this.keyType = keyType;
+ this.key = key;
+ }
+
+ /*
+ * Returns null when the line cannot be read. A key of a type that is not supported is kept as a null key.
+ */
+ static KnownHostsEntry parse(String line, SessionContext session) {
+ String[] fields = line.split("\\s+");
+ int index = 0;
+ String marker = null;
+ if (fields[0].startsWith("@")) {
+ marker = fields[0];
+ if (!MARKER_REVOKED.equals(marker) && !MARKER_CERT_AUTHORITY.equals(marker)) {
+ return null;
}
+ index = 1;
+ }
+ if (fields.length < index + 3) {
+ return null;
}
- tmpData[tmpCursor] = keyByteArray[keyByteArrayCursor];
- tmpCursor++;
- keyByteArrayCursor++;
- if (tmpCursor == tmpData.length) {
- sshPublicKeyHolder.push(tmpData);
- getLengthMode = true;
- tmpData = new byte[4];
- tmpCursor = 0;
+ String hosts = fields[index];
+ try {
+ byte[] salt = null;
+ byte[] hash = null;
+ if (hosts.startsWith("|")) {
+ // a hashed host: only |1|salt|hash, with a SHA-1 salt and hash, is supported
+ if (!hosts.startsWith(HASHED_HOST_PREFIX)) {
+ return null;
+ }
+ String[] parts = hosts.substring(HASHED_HOST_PREFIX.length()).split("\\|", -1);
+ if (parts.length != 2) {
+ return null;
+ }
+ salt = Base64.getDecoder().decode(parts[0]);
+ hash = Base64.getDecoder().decode(parts[1]);
+ if (salt.length != HASH_LENGTH || hash.length != HASH_LENGTH) {
+ return null;
+ }
+ }
+ PublicKeyEntry keyEntry = PublicKeyEntry.parsePublicKeyEntry(fields[index + 1] + " " + fields[index + 2]);
+ PublicKey key = keyEntry.resolvePublicKey(session, Collections.emptyMap(), PublicKeyEntryResolver.IGNORING);
+ return new KnownHostsEntry(marker, hosts, salt, hash, keyEntry.getKeyType(), key);
+ } catch (IOException | GeneralSecurityException | RuntimeException e) {
+ return null;
}
}
- return sshPublicKeyHolder.toPublicKey();
- }
+ boolean matches(String name) {
+ if (hash != null) {
+ return MessageDigest.isEqual(hash, hashOf(name));
+ }
+ boolean matched = false;
+ for (String pattern : hosts.split(",")) {
+ boolean negated = pattern.startsWith("!");
+ String hostPattern = (negated ? pattern.substring(1) : pattern).toLowerCase(Locale.ROOT);
+ if (!hostPattern.isEmpty() && matchesPattern(hostPattern, name)) {
+ if (negated) {
+ return false;
+ }
+ matched = true;
+ }
+ }
+ return matched;
+ }
- private int byteArrayToInt(byte[] tmpData) {
- return new BigInteger(tmpData).intValue();
+ private byte[] hashOf(String name) {
+ try {
+ Mac mac = Mac.getInstance(HASH_ALGORITHM);
+ mac.init(new SecretKeySpec(salt, HASH_ALGORITHM));
+ return mac.doFinal(name.getBytes(StandardCharsets.UTF_8));
+ } catch (GeneralSecurityException e) {
+ throw new IllegalStateException("Cannot compute the hash of a known_hosts host", e);
+ }
+ }
}
}
diff --git a/components/camel-ssh/src/main/java/org/apache/camel/component/ssh/SshConfiguration.java b/components/camel-ssh/src/main/java/org/apache/camel/component/ssh/SshConfiguration.java
index 4a9f7bf282169..717c996213454 100644
--- a/components/camel-ssh/src/main/java/org/apache/camel/component/ssh/SshConfiguration.java
+++ b/components/camel-ssh/src/main/java/org/apache/camel/component/ssh/SshConfiguration.java
@@ -56,11 +56,16 @@ public class SshConfiguration implements Cloneable {
private long timeout = 30000;
@UriParam(label = "security", security = "insecure:ssl",
description = "Sets the resource path for a known_hosts file used to verify the SSH server host key."
+ + " The file uses the OpenSSH known_hosts format, and its entries are matched against the"
+ + " host and port configured on the endpoint. A resource that cannot be read, or that has a"
+ + " line that cannot be read, makes every connection fail."
+ " When not set, the client does not verify the server host key against a known_hosts file.")
private String knownHostsResource;
@UriParam(label = "security", defaultValue = "false", security = "insecure:ssl", insecureValue = "false",
- description = "Specifies whether a connection to an unknown SSH server host key should fail. When false,"
- + " host keys that are not present in the known_hosts resource are accepted.")
+ description = "Specifies whether a connection to a server whose host has no entry without a marker in"
+ + " the known_hosts resource should fail. When false, such a server is accepted. A server"
+ + " whose host has entries without a marker is always rejected when it presents a key that"
+ + " none of them lists, and a key listed in a matching @revoked entry is always rejected.")
private boolean failOnUnknownHost;
@UriParam(label = "advanced", defaultValue = Channel.CHANNEL_EXEC)
private String channelType = Channel.CHANNEL_EXEC;
@@ -301,8 +306,10 @@ public boolean isFailOnUnknownHost() {
}
/**
- * Specifies whether a connection to an unknown host should fail or not. This value is only checked when the
- * property knownHosts is set.
+ * Specifies whether a connection to a server whose host has no entry without a marker in the known_hosts resource
+ * should fail or not. This value is only checked when the property knownHostsResource is set. A server whose host
+ * has entries without a marker is always rejected when it presents a key that none of them lists, and a key listed
+ * in a matching @revoked entry is always rejected.
*
* @param failOnUnknownHost boolean flag, whether a connection to an unknown host should fail
*/
diff --git a/components/camel-ssh/src/main/java/org/apache/camel/component/ssh/SshHelper.java b/components/camel-ssh/src/main/java/org/apache/camel/component/ssh/SshHelper.java
index b0703f1f30bdc..c26a4bc93fe58 100644
--- a/components/camel-ssh/src/main/java/org/apache/camel/component/ssh/SshHelper.java
+++ b/components/camel-ssh/src/main/java/org/apache/camel/component/ssh/SshHelper.java
@@ -18,6 +18,7 @@
import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
+import java.io.IOException;
import java.io.InputStream;
import java.io.PipedInputStream;
import java.io.PipedOutputStream;
@@ -33,12 +34,16 @@
import org.apache.sshd.client.SshClient;
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.config.hosts.HostConfigEntryResolver;
import org.apache.sshd.client.future.AuthFuture;
import org.apache.sshd.client.future.ConnectFuture;
import org.apache.sshd.client.future.OpenFuture;
import org.apache.sshd.client.session.ClientSession;
+import org.apache.sshd.common.AttributeRepository;
import org.apache.sshd.common.channel.Channel;
import org.apache.sshd.common.keyprovider.KeyPairProvider;
+import org.apache.sshd.common.util.net.SshdSocketAddress;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
@@ -49,6 +54,24 @@ public final class SshHelper {
private SshHelper() {
}
+ /*
+ * The host configuration for the host and port, resolved as SshClient.connect(username, host, port) resolves it: the
+ * entry that the host config resolver of the client (by default ~/.ssh/config) gives, or else the host itself.
+ */
+ static HostConfigEntry resolveHostConfig(SshClient client, String username, String host, int port)
+ throws IOException {
+ HostConfigEntryResolver resolver = client.getHostConfigEntryResolver();
+ HostConfigEntry entry
+ = resolver != null ? resolver.resolveEffectiveHost(host, port, null, username, null, null) : null;
+ if (entry == null) {
+ int effectivePort = port > 0 ? port : SshConfiguration.DEFAULT_SSH_PORT;
+ entry = SshdSocketAddress.isIPv6Address(host)
+ ? new HostConfigEntry("", host, effectivePort, username, null)
+ : new HostConfigEntry(host, host, effectivePort, username, null);
+ }
+ return entry;
+ }
+
public static SshResult sendExecCommand(Map headers, String command, SshEndpoint endpoint, SshClient client)
throws Exception {
SshConfiguration configuration = endpoint.getConfiguration();
@@ -63,7 +86,11 @@ public static SshResult sendExecCommand(Map headers, String comm
userName = (String) headers.get(SshConstants.USERNAME_HEADER);
}
- ConnectFuture connectFuture = client.connect(userName, configuration.getHost(), configuration.getPort());
+ // resolve the host configuration once, and connect with it: the host key is verified against the host that this
+ // connection goes to, as the host configuration writes it
+ HostConfigEntry hostConfig = resolveHostConfig(client, userName, configuration.getHost(), configuration.getPort());
+ ConnectFuture connectFuture = client.connect(hostConfig,
+ AttributeRepository.ofKeyValuePair(ResourceBasedSSHKeyVerifier.HOST_CONFIG_ENTRY, hostConfig), null);
// wait getTimeout milliseconds for connect operation to complete
connectFuture.await(configuration.getTimeout());
diff --git a/components/camel-ssh/src/test/java/org/apache/camel/component/ssh/ResourceBasedSSHKeyVerifierTest.java b/components/camel-ssh/src/test/java/org/apache/camel/component/ssh/ResourceBasedSSHKeyVerifierTest.java
index f383150e1b057..8bc5f317c6384 100644
--- a/components/camel-ssh/src/test/java/org/apache/camel/component/ssh/ResourceBasedSSHKeyVerifierTest.java
+++ b/components/camel-ssh/src/test/java/org/apache/camel/component/ssh/ResourceBasedSSHKeyVerifierTest.java
@@ -16,47 +16,359 @@
*/
package org.apache.camel.component.ssh;
-import java.io.IOException;
import java.io.InputStream;
-import java.security.NoSuchAlgorithmException;
+import java.lang.reflect.Array;
+import java.lang.reflect.Proxy;
+import java.net.InetAddress;
+import java.net.InetSocketAddress;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.security.KeyPairGenerator;
import java.security.PublicKey;
-import java.security.spec.InvalidKeySpecException;
-import java.util.LinkedHashMap;
-import java.util.Map;
+import java.security.spec.ECGenParameterSpec;
+import java.util.Arrays;
+import java.util.Base64;
+import java.util.Collections;
+import javax.crypto.Mac;
+import javax.crypto.spec.SecretKeySpec;
+
+import org.apache.camel.CamelContext;
+import org.apache.camel.impl.DefaultCamelContext;
+import org.apache.sshd.client.config.hosts.HostConfigEntry;
+import org.apache.sshd.client.session.ClientSession;
+import org.apache.sshd.client.session.ClientSessionCreator;
+import org.apache.sshd.common.AttributeRepository;
+import org.apache.sshd.common.config.keys.PublicKeyEntry;
+import org.apache.sshd.common.config.keys.PublicKeyEntryResolver;
import org.apache.sshd.common.util.io.IoUtils;
+import org.apache.sshd.common.util.net.SshdSocketAddress;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
-import org.slf4j.Logger;
-import org.slf4j.LoggerFactory;
+import org.junit.jupiter.api.io.TempDir;
+import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+class ResourceBasedSSHKeyVerifierTest {
+
+ @TempDir
+ Path dir;
+
+ private CamelContext context;
+ private PublicKey serverKey;
+ private PublicKey otherKey;
+
+ @BeforeEach
+ void setUp() throws Exception {
+ context = new DefaultCamelContext();
+ context.start();
+ KeyPairGenerator generator = KeyPairGenerator.getInstance("EC");
+ generator.initialize(new ECGenParameterSpec("secp256r1"));
+ serverKey = generator.generateKeyPair().getPublic();
+ otherKey = generator.generateKeyPair().getPublic();
+ }
+
+ @AfterEach
+ void tearDown() {
+ context.stop();
+ }
+
+ @Test
+ void acceptsTheKeyListedForTheHost() throws Exception {
+ assertTrue(verify(true, "db.example.com", 22, serverKey, entry("db.example.com", serverKey)));
+ assertTrue(verify(true, "db.example.com", 22, serverKey, entry("web.example.com,db.example.com", serverKey)));
+ }
-public class ResourceBasedSSHKeyVerifierTest {
+ @Test
+ void doesNotUseTheEntryOfAHostWhoseNameContainsTheHostName() throws Exception {
+ assertFalse(verify(true, "db.example.com", 22, otherKey, entry("old-db.example.com", otherKey)));
+ assertFalse(verify(true, "db.example.com", 22, otherKey, entry("db.example.com.old", otherKey)));
+ }
- public static final Logger LOG = LoggerFactory.getLogger(ResourceBasedSSHKeyVerifierTest.class);
+ @Test
+ void doesNotUseTheEntryOfAnAddressContainingTheAddress() throws Exception {
+ String knownHosts = entry("10.0.0.12", otherKey) + entry("10.0.0.1", serverKey);
+ assertFalse(verify(true, "10.0.0.1", 22, otherKey, knownHosts));
+ assertTrue(verify(true, "10.0.0.1", 22, serverKey, knownHosts));
+ assertFalse(verify(true, "10.0.0.1", 22, otherKey, entry("110.0.0.1", otherKey)));
+ }
@Test
- public void verifyAllTypesOfPublicKeys() throws IOException {
- final Map keys = new LinkedHashMap<>();
- InputStream knownHosts = getClass().getResourceAsStream("/known_hosts_types");
- assertNotNull(knownHosts);
- IoUtils.readAllLines(knownHosts).forEach(l -> {
- String[] tab = l.split(" ");
- if (l.startsWith("#")) {
- return;
+ void matchesThePort() throws Exception {
+ String knownHosts = entry("[db.example.com]:2222", otherKey);
+ assertFalse(verify(true, "db.example.com", 22, otherKey, knownHosts));
+ assertFalse(verify(true, "db.example.com", 222, otherKey, knownHosts));
+ assertTrue(verify(true, "db.example.com", 2222, otherKey, knownHosts));
+ // an entry without a port only applies to the default port
+ assertFalse(verify(true, "db.example.com", 2222, serverKey, entry("db.example.com", serverKey)));
+ }
+
+ @Test
+ void matchesPatternsAgainstTheHostAndPortAsOpenSshWritesThem() throws Exception {
+ assertTrue(verify(true, "db.example.com", 2222, serverKey, entry("*", serverKey)));
+ assertTrue(verify(true, "db.example.com", 2222, serverKey, entry("[*.example.com]:2222", serverKey)));
+ assertFalse(verify(true, "db.example.com", 2222, serverKey, entry("*.example.com", serverKey)));
+ assertTrue(verify(true, "db.example.com", 2222, serverKey, entry("[db.example.com]:22?2", serverKey)));
+ }
+
+ @Test
+ void matchesNegatedPortQualifiedPatterns() throws Exception {
+ String knownHosts = entry("![excluded.example.com]:2222,[db.example.com]:2222", serverKey);
+ for (boolean failOnUnknownHost : new boolean[] { true, false }) {
+ assertTrue(verify(failOnUnknownHost, "db.example.com", 2222, serverKey, knownHosts));
+ assertFalse(verify(failOnUnknownHost, "db.example.com", 2222, otherKey, knownHosts));
+ }
+ assertFalse(verify(true, "excluded.example.com", 2222, serverKey, knownHosts));
+ }
+
+ @Test
+ void matchesWildcardNegatedAndUpperCasePatterns() throws Exception {
+ assertTrue(verify(true, "db.example.com", 22, serverKey, entry("*.example.com", serverKey)));
+ assertTrue(verify(true, "db.example.com", 22, serverKey, entry("DB.EXAMPLE.COM", serverKey)));
+ String knownHosts = entry("!db.example.com,*.example.com", otherKey);
+ assertFalse(verify(true, "db.example.com", 22, otherKey, knownHosts));
+ assertTrue(verify(true, "www.example.com", 22, otherKey, knownHosts));
+ }
+
+ @Test
+ void matchesHashedEntries() throws Exception {
+ String knownHosts = entry(hashed("db.example.com"), serverKey);
+ assertTrue(verify(true, "db.example.com", 22, serverKey, knownHosts));
+ assertFalse(verify(true, "db.example.com", 22, otherKey, knownHosts));
+ assertFalse(verify(true, "www.example.com", 22, serverKey, knownHosts));
+ assertFalse(verify(true, "db.example.com", 2222, serverKey, knownHosts));
+ assertTrue(verify(true, "db.example.com", 2222, serverKey, entry(hashed("[db.example.com]:2222"), serverKey)));
+ }
+
+ @Test
+ void considersEveryEntryOfTheHost() throws Exception {
+ KeyPairGenerator generator = KeyPairGenerator.getInstance("RSA");
+ generator.initialize(2048);
+ PublicKey rsaKey = generator.generateKeyPair().getPublic();
+ String knownHosts = entry("db.example.com", rsaKey) + entry("db.example.com", serverKey);
+ assertTrue(verify(true, "db.example.com", 22, serverKey, knownHosts));
+ assertTrue(verify(true, "db.example.com", 22, rsaKey, knownHosts));
+ }
+
+ @Test
+ void readsCommentsAndCrlfLineEndings() throws Exception {
+ String knownHosts = "# known hosts\r\n" + "db.example.com " + PublicKeyEntry.toString(serverKey) + " db key\r\n";
+ assertTrue(verify(true, "db.example.com", 22, serverKey, knownHosts));
+ }
+
+ @Test
+ void rejectsARevokedKey() throws Exception {
+ String knownHosts = "@revoked * " + PublicKeyEntry.toString(otherKey) + "\n" + entry("db.example.com", otherKey);
+ assertFalse(verify(true, "db.example.com", 22, otherKey, knownHosts));
+ assertFalse(verify(false, "db.example.com", 22, otherKey, knownHosts));
+ assertTrue(verify(true, "db.example.com", 22, serverKey, knownHosts + entry("db.example.com", serverKey)));
+ }
+
+ @Test
+ void rejectsARevokedKeyOnEveryPortAndInEveryOrder() throws Exception {
+ String revoked = "@revoked * " + PublicKeyEntry.toString(serverKey) + "\n";
+ for (int port : new int[] { 22, 2222 }) {
+ String listed = entry(port == 22 ? "db.example.com" : "[db.example.com]:" + port, serverKey);
+ for (boolean failOnUnknownHost : new boolean[] { true, false }) {
+ assertFalse(verify(failOnUnknownHost, "db.example.com", port, serverKey, revoked + listed),
+ () -> "revocation before the entry, port " + port + ", failOnUnknownHost " + failOnUnknownHost);
+ assertFalse(verify(failOnUnknownHost, "db.example.com", port, serverKey, listed + revoked),
+ () -> "revocation after the entry, port " + port + ", failOnUnknownHost " + failOnUnknownHost);
}
- keys.put(tab[1], tab[2]);
- });
-
- ResourceBasedSSHKeyVerifier verifier = new ResourceBasedSSHKeyVerifier(null, null);
- keys.forEach((type, data) -> {
- LOG.info("Checking key {}", type);
- try {
- PublicKey publicKey = verifier.loadKey(data);
- assertNotNull(publicKey);
- } catch (NoSuchAlgorithmException | InvalidKeySpecException e) {
- throw new RuntimeException(e.getMessage(), e);
+ }
+ }
+
+ @Test
+ void treatsAHostWithOnlyRevokedEntriesAsUnknown() throws Exception {
+ // as OpenSSH does: a revoked entry rejects its key but does not list the host
+ String knownHosts = "@revoked db.example.com " + PublicKeyEntry.toString(otherKey) + "\n";
+ assertFalse(verify(true, "db.example.com", 22, serverKey, knownHosts));
+ assertTrue(verify(false, "db.example.com", 22, serverKey, knownHosts));
+ assertFalse(verify(false, "db.example.com", 22, otherKey, knownHosts));
+ }
+
+ @Test
+ void rejectsEveryServerWhenALineCannotBeRead() throws Exception {
+ String key = PublicKeyEntry.toString(serverKey);
+ String[] unreadableLines = {
+ "@unknown db.example.com " + key,
+ "@revoked",
+ "db.example.com",
+ "db.example.com ssh-ed25519 not*base64",
+ "|1|c2FsdA==|aGFzaA== " + key,
+ "|1|" + base64(20) + "| " + key,
+ "|1|" + base64(20) + "|" + base64(19) + " " + key,
+ "|1|" + base64(20) + "|" + base64(21) + " " + key,
+ "|2|" + base64(20) + "|" + base64(20) + " " + key };
+ for (String line : unreadableLines) {
+ for (String knownHosts : new String[] { line + "\n", entry("db.example.com", serverKey) + line + "\n" }) {
+ for (boolean failOnUnknownHost : new boolean[] { true, false }) {
+ assertFalse(verify(failOnUnknownHost, "db.example.com", 22, serverKey, knownHosts), line);
+ assertFalse(verify(failOnUnknownHost, "www.example.com", 22, serverKey, knownHosts), line);
+ }
+ }
+ }
+ }
+
+ @Test
+ void rejectsEveryServerWhenTheResourceCannotBeRead() {
+ for (boolean failOnUnknownHost : new boolean[] { true, false }) {
+ ResourceBasedSSHKeyVerifier verifier
+ = new ResourceBasedSSHKeyVerifier(context, "file:" + dir.resolve("missing"), failOnUnknownHost);
+ assertFalse(verifier.verifyServerKey(null, new InetSocketAddress("127.0.0.1", 22), serverKey));
+ }
+ }
+
+ @Test
+ void ignoresCertificateAuthorityEntries() throws Exception {
+ String knownHosts = "@cert-authority db.example.com " + PublicKeyEntry.toString(serverKey) + "\n";
+ assertFalse(verify(true, "db.example.com", 22, serverKey, knownHosts));
+ }
+
+ @Test
+ void rejectsAnotherKeyForAListedHostWhateverFailOnUnknownHost() throws Exception {
+ String knownHosts = entry("10.0.0.12", otherKey) + entry("10.0.0.1", serverKey);
+ assertFalse(verify(false, "10.0.0.1", 22, otherKey, knownHosts));
+ assertFalse(verify(false, "db.example.com", 22, otherKey, entry("db.example.com", serverKey)));
+ }
+
+ @Test
+ void appliesFailOnUnknownHostToHostsWithoutEntries() throws Exception {
+ String knownHosts = entry("www.example.com", serverKey);
+ assertFalse(verify(true, "db.example.com", 22, serverKey, knownHosts));
+ assertTrue(verify(false, "db.example.com", 22, serverKey, knownHosts));
+ assertFalse(verify(true, "db.example.com", 22, serverKey, ""));
+ assertTrue(verify(false, "db.example.com", 22, serverKey, ""));
+ }
+
+ @Test
+ void looksUpAnAddressAsConfigured() throws Exception {
+ // an endpoint configured with an address is looked up by that address, not by the name of a reverse lookup
+ String knownHosts = entry("localhost", otherKey) + entry("127.0.0.1", serverKey);
+ assertFalse(verify(true, new InetSocketAddress("127.0.0.1", 22), otherKey, knownHosts));
+ assertTrue(verify(true, new InetSocketAddress("127.0.0.1", 22), serverKey, knownHosts));
+ }
+
+ @Test
+ void looksUpTheHostAsTheHostConfigurationOfTheConnectionWritesIt() throws Exception {
+ // the address the client connected to writes ::1 as 0:0:0:0:0:0:0:1
+ InetSocketAddress ipv6 = new InetSocketAddress("::1", 2222);
+ String knownHosts = entry("[::1]:2222", serverKey);
+ for (String hostName : new String[] { "::1", "[::1]" }) {
+ // as an endpoint configured with ::1, or Host review-ipv6 / HostName ::1 in ~/.ssh/config, connects
+ ClientSession session = session(new HostConfigEntry("review-ipv6", hostName, 2222, "smx"), null);
+ for (boolean failOnUnknownHost : new boolean[] { true, false }) {
+ assertTrue(verify(failOnUnknownHost, session, ipv6, serverKey, knownHosts), hostName);
+ assertFalse(verify(failOnUnknownHost, session, ipv6, otherKey, knownHosts), hostName);
}
- });
+ }
+ // Host db / HostName db.example.com in ~/.ssh/config
+ ClientSession db = session(new HostConfigEntry("db", "db.example.com", 22, "smx"), null);
+ InetSocketAddress dbAddress
+ = new InetSocketAddress(InetAddress.getByAddress("db.example.com", new byte[] { 10, 0, 0, 1 }), 22);
+ assertTrue(verify(true, db, dbAddress, serverKey, entry("db.example.com", serverKey)));
+ assertFalse(verify(true, db, dbAddress, serverKey, entry("db", serverKey)));
+ assertFalse(verify(false, db, dbAddress, otherKey, entry("db.example.com", serverKey)));
+ // without the host configuration of the connection, the connected address is used as it writes the host
+ assertFalse(verify(true, session(null, null), ipv6, serverKey, knownHosts));
+ }
+
+ @Test
+ void looksUpAHostReachedThroughAJumpHostAsItIsWritten() throws Exception {
+ HostConfigEntry viaJump = new HostConfigEntry("review-ipv6", "::1", 2222, "smx", "jump.example.com");
+ // the target, which SSHD connects to through the jump host and keeps as it is written
+ ClientSession target = session(viaJump, new SshdSocketAddress("::1", 2222));
+ InetSocketAddress ipv6 = new InetSocketAddress("::1", 2222);
+ assertTrue(verify(true, target, ipv6, serverKey, entry("[::1]:2222", serverKey)));
+ assertFalse(verify(false, target, ipv6, otherKey, entry("[::1]:2222", serverKey)));
+ // the jump host itself is looked up by its own address, not by the name of the target
+ ClientSession jump = session(viaJump, null);
+ InetSocketAddress jumpAddress
+ = new InetSocketAddress(InetAddress.getByAddress("jump.example.com", new byte[] { 10, 0, 0, 2 }), 22);
+ String knownHosts = entry("jump.example.com", otherKey) + entry("[::1]:2222", serverKey);
+ assertTrue(verify(true, jump, jumpAddress, otherKey, knownHosts));
+ assertFalse(verify(true, jump, jumpAddress, serverKey, knownHosts));
+ }
+
+ @Test
+ void verifiesAllSupportedKeyTypes() throws Exception {
+ ResourceBasedSSHKeyVerifier verifier = new ResourceBasedSSHKeyVerifier(context, "classpath:known_hosts_types", true);
+ try (InputStream knownHosts = getClass().getResourceAsStream("/known_hosts_types")) {
+ assertNotNull(knownHosts);
+ for (String line : IoUtils.readAllLines(knownHosts)) {
+ String[] fields = line.split(" ");
+ PublicKey key = PublicKeyEntry.parsePublicKeyEntry(fields[1] + " " + fields[2])
+ .resolvePublicKey(null, Collections.emptyMap(), PublicKeyEntryResolver.FAILING);
+ assertTrue(verifier.verifyServerKey(null, new InetSocketAddress(fields[0], 22), key), fields[1]);
+ assertFalse(verifier.verifyServerKey(null, new InetSocketAddress(fields[0], 22), serverKey), fields[1]);
+ }
+ }
+ }
+
+ private boolean verify(boolean failOnUnknownHost, String host, int port, PublicKey presented, String knownHosts)
+ throws Exception {
+ // the SSH client hands the verifier the configured host name together with its resolved address
+ InetAddress address = InetAddress.getByAddress(host, new byte[] { 10, 0, 0, 1 });
+ return verify(failOnUnknownHost, new InetSocketAddress(address, port), presented, knownHosts);
+ }
+
+ private boolean verify(
+ boolean failOnUnknownHost, InetSocketAddress server, PublicKey presented, String knownHosts)
+ throws Exception {
+ return verify(failOnUnknownHost, null, server, presented, knownHosts);
+ }
+
+ private boolean verify(
+ boolean failOnUnknownHost, ClientSession session, InetSocketAddress server, PublicKey presented,
+ String knownHosts)
+ throws Exception {
+ Path file = Files.createTempFile(dir, "known_hosts", "");
+ Files.writeString(file, knownHosts, StandardCharsets.UTF_8);
+ ResourceBasedSSHKeyVerifier verifier = new ResourceBasedSSHKeyVerifier(context, "file:" + file, failOnUnknownHost);
+ return verifier.verifyServerKey(session, server, presented);
+ }
+
+ /*
+ * A session connected with the given host configuration, as the ssh producer and consumer connect, and reached
+ * through a jump host when a target server is given: all that the verifier asks the session for.
+ */
+ private static ClientSession session(HostConfigEntry hostConfig, SshdSocketAddress targetServer) {
+ AttributeRepository connectionContext = hostConfig != null
+ ? AttributeRepository.ofKeyValuePair(ResourceBasedSSHKeyVerifier.HOST_CONFIG_ENTRY, hostConfig) : null;
+ return (ClientSession) Proxy.newProxyInstance(ClientSession.class.getClassLoader(),
+ new Class>[] { ClientSession.class }, (proxy, method, args) -> {
+ switch (method.getName()) {
+ case "getConnectionContext":
+ return connectionContext;
+ case "getAttribute":
+ return args[0] == ClientSessionCreator.TARGET_SERVER ? targetServer : null;
+ default:
+ Class> type = method.getReturnType();
+ // the default value of a primitive type, null otherwise
+ return type.isPrimitive() && type != void.class
+ ? Array.get(Array.newInstance(type, 1), 0) : null;
+ }
+ });
+ }
+
+ private static String base64(int length) {
+ return Base64.getEncoder().encodeToString(new byte[length]);
+ }
+
+ private static String entry(String hosts, PublicKey key) {
+ return hosts + " " + PublicKeyEntry.toString(key) + "\n";
+ }
+
+ private static String hashed(String host) throws Exception {
+ byte[] salt = new byte[20];
+ Arrays.fill(salt, (byte) 7);
+ Mac mac = Mac.getInstance("HmacSHA1");
+ mac.init(new SecretKeySpec(salt, "HmacSHA1"));
+ byte[] hash = mac.doFinal(host.getBytes(StandardCharsets.UTF_8));
+ return "|1|" + Base64.getEncoder().encodeToString(salt) + "|" + Base64.getEncoder().encodeToString(hash);
}
}
diff --git a/components/camel-ssh/src/test/java/org/apache/camel/component/ssh/SshComponentKnownHostEdDSATest.java b/components/camel-ssh/src/test/java/org/apache/camel/component/ssh/SshComponentKnownHostEdDSATest.java
index 243610be3cad1..2fc472611aa7e 100644
--- a/components/camel-ssh/src/test/java/org/apache/camel/component/ssh/SshComponentKnownHostEdDSATest.java
+++ b/components/camel-ssh/src/test/java/org/apache/camel/component/ssh/SshComponentKnownHostEdDSATest.java
@@ -22,6 +22,14 @@
public class SshComponentKnownHostEdDSATest extends SshComponentTestSupport {
+ private String knownHosts;
+
+ @Override
+ public void doPreSetup() throws Exception {
+ super.doPreSetup();
+ knownHosts = writeKnownHosts("known_hosts_eddsa", "[localhost]:" + port + " " + serverPublicKey());
+ }
+
@Test
public void testProducerWithEdDSAKeyType() throws Exception {
final String msg = "test";
@@ -50,12 +58,13 @@ public void configure() {
onException(Exception.class).handled(true).to("mock:error");
from("ssh://smx:smx@localhost:" + port
- + "?useFixedDelay=true&delay=40000&pollCommand=test%0A&knownHostsResource=classpath:known_hosts_eddsa&failOnUnknownHost=true")
+ + "?useFixedDelay=true&delay=40000&pollCommand=test%0A&knownHostsResource=" + knownHosts
+ + "&failOnUnknownHost=true")
.to("mock:result");
from("direct:ssh")
.to("ssh://smx:smx@localhost:" + port
- + "?timeout=3000&knownHostsResource=classpath:known_hosts_eddsa&failOnUnknownHost=true")
+ + "?timeout=3000&knownHostsResource=" + knownHosts + "&failOnUnknownHost=true")
.to("mock:password");
}
};
diff --git a/components/camel-ssh/src/test/java/org/apache/camel/component/ssh/SshComponentKnownHostIPv6Test.java b/components/camel-ssh/src/test/java/org/apache/camel/component/ssh/SshComponentKnownHostIPv6Test.java
new file mode 100644
index 0000000000000..45d390cec790e
--- /dev/null
+++ b/components/camel-ssh/src/test/java/org/apache/camel/component/ssh/SshComponentKnownHostIPv6Test.java
@@ -0,0 +1,183 @@
+/*
+ * 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.camel.component.ssh;
+
+import java.io.IOException;
+import java.net.InetSocketAddress;
+import java.net.Socket;
+import java.security.KeyPairGenerator;
+import java.security.spec.ECGenParameterSpec;
+import java.util.concurrent.atomic.AtomicBoolean;
+
+import org.apache.camel.builder.RouteBuilder;
+import org.apache.camel.component.mock.MockEndpoint;
+import org.apache.sshd.client.ClientBuilder;
+import org.apache.sshd.client.config.hosts.HostConfigEntry;
+import org.apache.sshd.client.config.hosts.HostConfigEntryResolver;
+import org.apache.sshd.common.config.keys.PublicKeyEntry;
+import org.junit.jupiter.api.Test;
+
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.junit.jupiter.api.Assumptions.assumeTrue;
+
+/**
+ * A server reached at the IPv6 address ::1 is looked up as [::1]:port, as the address is written, although the address
+ * the client connects to writes it as 0:0:0:0:0:0:0:1: both when the endpoint is configured with ::1, and when the
+ * endpoint is configured with an alias that the host configuration of the client (as ~/.ssh/config would) gives the
+ * HostName ::1 for. The host is set on the endpoint, as the endpoint uri does not accept an IPv6 address.
+ */
+class SshComponentKnownHostIPv6Test extends SshComponentTestSupport {
+
+ private static final String ALIAS = "review-ipv6";
+
+ private String knownHostsServerKey;
+ private String knownHostsOtherKey;
+
+ @Override
+ public void doPreSetup() throws Exception {
+ super.doPreSetup();
+ assumeTrue(isIPv6LoopbackAvailable(), "The IPv6 loopback address is not available");
+ knownHostsServerKey = writeKnownHosts("known_hosts_server_key", "[::1]:" + port + " " + serverPublicKey());
+ KeyPairGenerator generator = KeyPairGenerator.getInstance("EC");
+ generator.initialize(new ECGenParameterSpec("secp256r1"));
+ String otherKey = PublicKeyEntry.toString(generator.generateKeyPair().getPublic());
+ knownHostsOtherKey = writeKnownHosts("known_hosts_other_key", "[::1]:" + port + " " + otherKey);
+ }
+
+ @Test
+ void testProducerWithTheServerKey() throws Exception {
+ assertDelivered("serverKey");
+ }
+
+ @Test
+ void testProducerWithTheServerKeyAcceptingUnknownHosts() throws Exception {
+ assertDelivered("serverKeyAcceptingUnknownHosts");
+ }
+
+ @Test
+ void testProducerWithAnotherKey() throws Exception {
+ assertRejected("otherKey");
+ }
+
+ @Test
+ void testProducerWithAnotherKeyAcceptingUnknownHosts() throws Exception {
+ assertRejected("otherKeyAcceptingUnknownHosts");
+ }
+
+ @Test
+ void testProducerWithAnAliasAndTheServerKey() throws Exception {
+ assertDelivered("aliasServerKey");
+ }
+
+ @Test
+ void testProducerWithAnAliasAndTheServerKeyAcceptingUnknownHosts() throws Exception {
+ assertDelivered("aliasServerKeyAcceptingUnknownHosts");
+ }
+
+ @Test
+ void testProducerWithAnAliasAndAnotherKey() throws Exception {
+ assertRejected("aliasOtherKey");
+ }
+
+ @Test
+ void testProducerWithAnAliasAndAnotherKeyAcceptingUnknownHosts() throws Exception {
+ assertRejected("aliasOtherKeyAcceptingUnknownHosts");
+ }
+
+ /*
+ * Each route uses its own user name, which the test server records when it receives credentials.
+ */
+ private void assertDelivered(String route) throws Exception {
+ MockEndpoint mock = getMockEndpoint("mock:result");
+ mock.expectedMessageCount(1);
+ mock.expectedBodiesReceived("test");
+ getMockEndpoint("mock:error").expectedMessageCount(0);
+
+ template.sendBody("direct:" + route, "test");
+
+ MockEndpoint.assertIsSatisfied(context);
+ assertTrue(authenticatedUsers.contains(route), "the server should receive the credentials");
+ }
+
+ private void assertRejected(String route) throws Exception {
+ getMockEndpoint("mock:result").expectedMessageCount(0);
+ getMockEndpoint("mock:error").expectedMessageCount(1);
+
+ template.sendBody("direct:" + route, "test");
+
+ MockEndpoint.assertIsSatisfied(context);
+ assertFalse(authenticatedUsers.contains(route), "the server should not receive any credentials");
+ }
+
+ private boolean isIPv6LoopbackAvailable() {
+ try (Socket socket = new Socket()) {
+ socket.connect(new InetSocketAddress("::1", port), 2000);
+ return true;
+ } catch (IOException e) {
+ return false;
+ }
+ }
+
+ /*
+ * The host configuration of the client, as ~/.ssh/config would give it: Host review-ipv6, HostName ::1. It answers
+ * only once, so the host key must be checked against the host that the connection was resolved to, without asking
+ * the host configuration again.
+ */
+ private static HostConfigEntryResolver resolvingOnce() {
+ AtomicBoolean resolved = new AtomicBoolean();
+ return (host, hostPort, localAddress, username, proxyJump, attributes) -> {
+ if (!ALIAS.equals(host)) {
+ return null;
+ }
+ if (resolved.getAndSet(true)) {
+ throw new IOException("The host configuration answers only once");
+ }
+ return new HostConfigEntry(host, "::1", hostPort, username);
+ };
+ }
+
+ @Override
+ protected RouteBuilder createRouteBuilder() {
+ return new RouteBuilder() {
+ @Override
+ public void configure() {
+ onException(Exception.class).handled(true).to("mock:error");
+
+ route("serverKey", "::1", knownHostsServerKey, true);
+ route("serverKeyAcceptingUnknownHosts", "::1", knownHostsServerKey, false);
+ route("otherKey", "::1", knownHostsOtherKey, true);
+ route("otherKeyAcceptingUnknownHosts", "::1", knownHostsOtherKey, false);
+ route("aliasServerKey", ALIAS, knownHostsServerKey, true);
+ route("aliasServerKeyAcceptingUnknownHosts", ALIAS, knownHostsServerKey, false);
+ route("aliasOtherKey", ALIAS, knownHostsOtherKey, true);
+ route("aliasOtherKeyAcceptingUnknownHosts", ALIAS, knownHostsOtherKey, false);
+ }
+
+ private void route(String name, String host, String knownHosts, boolean failOnUnknownHost) {
+ SshEndpoint endpoint = context.getEndpoint("ssh://" + name + ":secret@localhost:" + port
+ + "?timeout=3000&knownHostsResource=" + knownHosts
+ + "&failOnUnknownHost=" + failOnUnknownHost,
+ SshEndpoint.class);
+ endpoint.setHost(host);
+ endpoint.getConfiguration()
+ .setClientBuilder(ClientBuilder.builder().hostConfigEntryResolver(resolvingOnce()));
+ from("direct:" + name).to(endpoint).to("mock:result");
+ }
+ };
+ }
+}
diff --git a/components/camel-ssh/src/test/java/org/apache/camel/component/ssh/SshComponentKnownHostTest.java b/components/camel-ssh/src/test/java/org/apache/camel/component/ssh/SshComponentKnownHostTest.java
index 72915c883e7b8..977e28e276053 100644
--- a/components/camel-ssh/src/test/java/org/apache/camel/component/ssh/SshComponentKnownHostTest.java
+++ b/components/camel-ssh/src/test/java/org/apache/camel/component/ssh/SshComponentKnownHostTest.java
@@ -16,12 +16,42 @@
*/
package org.apache.camel.component.ssh;
+import java.io.IOException;
+import java.security.KeyPairGenerator;
+import java.security.spec.ECGenParameterSpec;
+
import org.apache.camel.builder.RouteBuilder;
import org.apache.camel.component.mock.MockEndpoint;
+import org.apache.sshd.client.ClientBuilder;
+import org.apache.sshd.client.config.hosts.HostConfigEntry;
+import org.apache.sshd.common.config.keys.PublicKeyEntry;
import org.junit.jupiter.api.Test;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
public class SshComponentKnownHostTest extends SshComponentTestSupport {
+ private String knownHostsValid;
+ private String knownHostsOtherHost;
+ private String knownHostsSeveralKeys;
+ private String knownHostsOtherKey;
+
+ @Override
+ public void doPreSetup() throws Exception {
+ super.doPreSetup();
+ String serverKey = serverPublicKey();
+ String serverEntry = "[localhost]:" + port + " " + serverKey;
+ knownHostsValid = writeKnownHosts("known_hosts_valid", serverEntry);
+ knownHostsOtherHost = writeKnownHosts("known_hosts_other_host", "old-localhost " + serverKey);
+ KeyPairGenerator generator = KeyPairGenerator.getInstance("EC");
+ generator.initialize(new ECGenParameterSpec("secp256r1"));
+ String otherKey = PublicKeyEntry.toString(generator.generateKeyPair().getPublic());
+ knownHostsSeveralKeys
+ = writeKnownHosts("known_hosts_several_keys", "[localhost]:" + port + " " + otherKey, serverEntry);
+ knownHostsOtherKey = writeKnownHosts("known_hosts_other_key", "[localhost]:" + port + " " + otherKey);
+ }
+
@Test
public void testProducerWithValidFile() throws Exception {
final String msg = "test";
@@ -64,6 +94,73 @@ public void testProducerWithInvalidFileWarnOnly() throws Exception {
MockEndpoint.assertIsSatisfied(context);
}
+ @Test
+ void testProducerWithKeyListedForAnotherHost() throws Exception {
+ MockEndpoint mock = getMockEndpoint("mock:password");
+ mock.expectedMessageCount(0);
+ getMockEndpoint("mock:error").expectedMessageCount(1);
+
+ template.sendBody("direct:sshOtherHost", "test");
+
+ MockEndpoint.assertIsSatisfied(context);
+ }
+
+ @Test
+ void testProducerWithSeveralKeysForTheServer() throws Exception {
+ final String msg = "test";
+
+ MockEndpoint mock = getMockEndpoint("mock:password");
+ mock.expectedMessageCount(1);
+ mock.expectedBodiesReceived(msg);
+
+ template.sendBody("direct:sshSeveralKeys", msg);
+
+ MockEndpoint.assertIsSatisfied(context);
+ }
+
+ @Test
+ void testProducerWithAnAliasInTheHostConfiguration() throws Exception {
+ MockEndpoint mock = getMockEndpoint("mock:password");
+ mock.expectedMessageCount(1);
+ mock.expectedBodiesReceived("test");
+
+ template.sendBody("direct:aliasServerKey", "test");
+
+ MockEndpoint.assertIsSatisfied(context);
+ assertTrue(authenticatedUsers.contains("aliasServerKey"));
+ }
+
+ @Test
+ void testProducerWithAnAliasInTheHostConfigurationAndAnotherKey() throws Exception {
+ getMockEndpoint("mock:password").expectedMessageCount(0);
+ getMockEndpoint("mock:error").expectedMessageCount(1);
+
+ template.sendBody("direct:aliasOtherKey", "test");
+
+ MockEndpoint.assertIsSatisfied(context);
+ assertFalse(authenticatedUsers.contains("aliasOtherKey"));
+ }
+
+ @Test
+ void testProducerWithAFailingHostConfiguration() throws Exception {
+ assertNotConnected("brokenAlias");
+ }
+
+ @Test
+ void testProducerWithAFailingHostConfigurationAcceptingUnknownHosts() throws Exception {
+ assertNotConnected("brokenAliasAcceptingUnknownHosts");
+ }
+
+ private void assertNotConnected(String route) throws Exception {
+ getMockEndpoint("mock:password").expectedMessageCount(0);
+ getMockEndpoint("mock:error").expectedMessageCount(1);
+
+ template.sendBody("direct:" + route, "test");
+
+ MockEndpoint.assertIsSatisfied(context);
+ assertFalse(authenticatedUsers.contains(route));
+ }
+
@Test
public void testPollingConsumerWithValidKnownHostFile() throws Exception {
MockEndpoint mock = getMockEndpoint("mock:result");
@@ -99,7 +196,8 @@ public void configure() {
onException(Exception.class).handled(true).to("mock:error");
from("ssh://smx:smx@localhost:" + port
- + "?useFixedDelay=true&delay=40000&pollCommand=test%0A&knownHostsResource=classpath:known_hosts_valid&failOnUnknownHost=true")
+ + "?useFixedDelay=true&delay=40000&pollCommand=test%0A&knownHostsResource=" + knownHostsValid
+ + "&failOnUnknownHost=true")
.to("mock:result");
from("ssh://smx:smx@localhost:" + port
@@ -112,7 +210,28 @@ public void configure() {
from("direct:ssh")
.to("ssh://smx:smx@localhost:" + port
- + "?timeout=3000&knownHostsResource=classpath:known_hosts_valid&failOnUnknownHost=true")
+ + "?timeout=3000&knownHostsResource=" + knownHostsValid + "&failOnUnknownHost=true")
+ .to("mock:password");
+
+ from("direct:sshOtherHost")
+ .to("ssh://smx:smx@localhost:" + port
+ + "?timeout=3000&knownHostsResource=" + knownHostsOtherHost + "&failOnUnknownHost=true")
+ .to("mock:password");
+
+ // the host configuration of the client, as ~/.ssh/config would give it: Host db-alias, HostName localhost
+ from("direct:aliasServerKey").to(aliasEndpoint("aliasServerKey", knownHostsValid, true))
+ .to("mock:password");
+ from("direct:aliasOtherKey").to(aliasEndpoint("aliasOtherKey", knownHostsOtherKey, false))
+ .to("mock:password");
+
+ // a host configuration that cannot resolve the host: the producer must not connect anywhere
+ from("direct:brokenAlias").to(brokenAliasEndpoint("brokenAlias", true)).to("mock:password");
+ from("direct:brokenAliasAcceptingUnknownHosts")
+ .to(brokenAliasEndpoint("brokenAliasAcceptingUnknownHosts", false)).to("mock:password");
+
+ from("direct:sshSeveralKeys")
+ .to("ssh://smx:smx@localhost:" + port
+ + "?timeout=3000&knownHostsResource=" + knownHostsSeveralKeys + "&failOnUnknownHost=true")
.to("mock:password");
from("direct:sshInvalid").to("ssh://smx:smx@localhost:" + port
@@ -125,4 +244,27 @@ public void configure() {
}
};
}
+
+ private SshEndpoint aliasEndpoint(String user, String knownHosts, boolean failOnUnknownHost) {
+ SshEndpoint endpoint = context.getEndpoint("ssh://" + user + ":secret@db-alias:" + port
+ + "?timeout=3000&knownHostsResource=" + knownHosts
+ + "&failOnUnknownHost=" + failOnUnknownHost,
+ SshEndpoint.class);
+ endpoint.getConfiguration().setClientBuilder(ClientBuilder.builder().hostConfigEntryResolver(
+ (host, hostPort, localAddress, username, proxyJump, attributes) -> "db-alias".equals(host)
+ ? new HostConfigEntry(host, "localhost", hostPort, username) : null));
+ return endpoint;
+ }
+
+ private SshEndpoint brokenAliasEndpoint(String user, boolean failOnUnknownHost) {
+ SshEndpoint endpoint = context.getEndpoint("ssh://" + user + ":secret@broken-alias:" + port
+ + "?timeout=3000&knownHostsResource=" + knownHostsValid
+ + "&failOnUnknownHost=" + failOnUnknownHost,
+ SshEndpoint.class);
+ endpoint.getConfiguration().setClientBuilder(ClientBuilder.builder().hostConfigEntryResolver(
+ (host, hostPort, localAddress, username, proxyJump, attributes) -> {
+ throw new IOException("Cannot read the host configuration");
+ }));
+ return endpoint;
+ }
}
diff --git a/components/camel-ssh/src/test/java/org/apache/camel/component/ssh/SshComponentTestSupport.java b/components/camel-ssh/src/test/java/org/apache/camel/component/ssh/SshComponentTestSupport.java
index e0260028ae437..db4b23cfea6b9 100644
--- a/components/camel-ssh/src/test/java/org/apache/camel/component/ssh/SshComponentTestSupport.java
+++ b/components/camel-ssh/src/test/java/org/apache/camel/component/ssh/SshComponentTestSupport.java
@@ -17,15 +17,26 @@
package org.apache.camel.component.ssh;
import java.io.IOException;
+import java.nio.file.Files;
+import java.nio.file.Path;
import java.nio.file.Paths;
+import java.security.GeneralSecurityException;
+import java.security.KeyPair;
+import java.util.Set;
+import java.util.concurrent.ConcurrentHashMap;
import org.apache.camel.test.junit6.CamelTestSupport;
+import org.apache.sshd.common.config.keys.PublicKeyEntry;
import org.apache.sshd.common.keyprovider.FileKeyPairProvider;
import org.apache.sshd.server.SshServer;
public class SshComponentTestSupport extends CamelTestSupport {
protected SshServer sshd;
protected int port;
+ /**
+ * The users the test server received credentials from, a password or a public key.
+ */
+ protected final Set authenticatedUsers = ConcurrentHashMap.newKeySet();
@Override
public void doPreSetup() throws Exception {
@@ -33,8 +44,14 @@ public void doPreSetup() throws Exception {
sshd.setPort(0);
sshd.setKeyPairProvider(new FileKeyPairProvider(Paths.get(getHostKey())));
sshd.setCommandFactory(new TestEchoCommandFactory());
- sshd.setPasswordAuthenticator((username, password, session) -> true);
- sshd.setPublickeyAuthenticator((username, key, session) -> true);
+ sshd.setPasswordAuthenticator((username, password, session) -> {
+ authenticatedUsers.add(username);
+ return true;
+ });
+ sshd.setPublickeyAuthenticator((username, key, session) -> {
+ authenticatedUsers.add(username);
+ return true;
+ });
sshd.start();
port = sshd.getPort();
}
@@ -43,6 +60,25 @@ protected String getHostKey() {
return "src/test/resources/hostkey.pem";
}
+ /**
+ * The public key of the test server, in the form used by a known_hosts entry.
+ */
+ protected String serverPublicKey() throws IOException, GeneralSecurityException {
+ KeyPair keyPair = sshd.getKeyPairProvider().loadKeys(null).iterator().next();
+ return PublicKeyEntry.toString(keyPair.getPublic());
+ }
+
+ /**
+ * Writes a known_hosts file for this test and returns it as a resource. The test server listens on a random port,
+ * so its entries must use the [localhost]:port form.
+ */
+ protected String writeKnownHosts(String name, String... entries) throws IOException {
+ Path file = Paths.get("target", "known_hosts", getClass().getSimpleName(), name);
+ Files.createDirectories(file.getParent());
+ Files.writeString(file, String.join("\n", entries) + "\n");
+ return "file:" + file;
+ }
+
@Override
public void doPostTearDown() throws IOException, InterruptedException {
if (sshd != null) {
diff --git a/components/camel-ssh/src/test/resources/known_hosts_eddsa b/components/camel-ssh/src/test/resources/known_hosts_eddsa
deleted file mode 100644
index 8ccd01181c5c3..0000000000000
--- a/components/camel-ssh/src/test/resources/known_hosts_eddsa
+++ /dev/null
@@ -1 +0,0 @@
-127.0.0.1 ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIGY16PbGw2BjMtxaUUWBYK9TKVeavaOXG2FvlAEe80x8
diff --git a/components/camel-ssh/src/test/resources/known_hosts_valid b/components/camel-ssh/src/test/resources/known_hosts_valid
deleted file mode 100644
index e7ae1daf75e11..0000000000000
--- a/components/camel-ssh/src/test/resources/known_hosts_valid
+++ /dev/null
@@ -1 +0,0 @@
-127.0.0.1 ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAAAgQDdfIWeSV4o68dRrKSzFd/Bk51E65UTmmSrmW0O1ohtzi6HzsDPjXgCtlTt3FqTcfFfI92IlTr4JWqC9UK1QT1ZTeng0MkPQmv68hDANHbt5CpETZHjW5q4OOgWhVvj5IyOC2NZHtKlJBkdsMAa15ouOOJLzBvAvbqOR/yUROsEiQ==
\ No newline at end of file
diff --git a/docs/user-manual/modules/ROOT/pages/camel-4x-upgrade-guide-4_18.adoc b/docs/user-manual/modules/ROOT/pages/camel-4x-upgrade-guide-4_18.adoc
index 8c3dc42817e1a..82a105736ec18 100644
--- a/docs/user-manual/modules/ROOT/pages/camel-4x-upgrade-guide-4_18.adoc
+++ b/docs/user-manual/modules/ROOT/pages/camel-4x-upgrade-guide-4_18.adoc
@@ -37,6 +37,35 @@ A saga option named like a parameter of the participant callback URLs, in any ca
`Camel-Saga-Complete`, `Camel-Saga-Signature` or `Camel-Saga-Lra`), now fails the creation of its route, with or
without a callback secret. The value of such an option could not be read back from the callback before.
+=== camel-ssh - known_hosts entries are matched as in OpenSSH
+
+When `knownHostsResource` is set, the entries of the `known_hosts` file are now matched as OpenSSH
+matches them, against the host name or address the client connects to, written `host` for port 22 and
+`[host]:port` for another port:
+
+* A host pattern must match that name as a whole. An entry for another host whose name or address
+contains the configured one is no longer used.
+* A pattern such as `db.example.com` only applies to port 22. A server on another port needs an entry
+such as `[db.example.com]:2222`, or a pattern that matches that form, such as `*`.
+* The host is used as it is written: the host configured on the endpoint, or the `HostName` that
+`~/.ssh/config` gives for it. An endpoint configured with a host name no longer uses entries listed
+only under its address, an endpoint configured with an address no longer uses entries listed under the
+name that a reverse lookup of the address returns, and an IPv6 address such as `::1` is matched as
+written instead of in its expanded form.
+* Every entry of the host is considered, so a host can be listed with several keys. Wildcard, negated
+and hashed patterns, comments and `@revoked` entries are supported. `@cert-authority` entries are
+ignored.
+* A `known_hosts` resource that cannot be read, or that has a line that cannot be read (for example an
+unknown `@` marker, a missing key, or a key that is not valid base64), now makes every connection fail,
+whatever the value of `failOnUnknownHost`. Previously such a resource was treated as having no entry,
+and such a line was skipped.
+
+As before, a server whose host has entries without a marker is rejected when it presents a key that
+none of them lists, whatever the value of `failOnUnknownHost`, which only applies to a host without
+such an entry: `@revoked` and `@cert-authority` entries do not list a host.
+Check that the `known_hosts` file has an entry for the host name or address, and the port, that each
+endpoint is configured with.
+
=== camel-netty-http - the roles of a security constraint are matched by role name
The roles of a `SecurityConstraintMapping` inclusion are a comma-separated list of role names, and an authenticated
diff --git a/docs/user-manual/modules/ROOT/pages/camel-4x-upgrade-guide-4_22.adoc b/docs/user-manual/modules/ROOT/pages/camel-4x-upgrade-guide-4_22.adoc
index df13ee119eb9a..35fb4b7072027 100644
--- a/docs/user-manual/modules/ROOT/pages/camel-4x-upgrade-guide-4_22.adoc
+++ b/docs/user-manual/modules/ROOT/pages/camel-4x-upgrade-guide-4_22.adoc
@@ -27,6 +27,35 @@ when `enableSniffer` is set, the nodes the sniffer finds, matching the scheme, h
registered for any host. A request to another host, including the host of a node with another scheme or port, is now
sent without them. A client set with the `restClient` option is not changed.
+=== camel-ssh - known_hosts entries are matched as in OpenSSH
+
+When `knownHostsResource` is set, the entries of the `known_hosts` file are now matched as OpenSSH
+matches them, against the host name or address the client connects to, written `host` for port 22 and
+`[host]:port` for another port:
+
+* A host pattern must match that name as a whole. An entry for another host whose name or address
+contains the configured one is no longer used.
+* A pattern such as `db.example.com` only applies to port 22. A server on another port needs an entry
+such as `[db.example.com]:2222`, or a pattern that matches that form, such as `*`.
+* The host is used as it is written: the host configured on the endpoint, or the `HostName` that
+`~/.ssh/config` gives for it. An endpoint configured with a host name no longer uses entries listed
+only under its address, an endpoint configured with an address no longer uses entries listed under the
+name that a reverse lookup of the address returns, and an IPv6 address such as `::1` is matched as
+written instead of in its expanded form.
+* Every entry of the host is considered, so a host can be listed with several keys. Wildcard, negated
+and hashed patterns, comments and `@revoked` entries are supported. `@cert-authority` entries are
+ignored.
+* A `known_hosts` resource that cannot be read, or that has a line that cannot be read (for example an
+unknown `@` marker, a missing key, or a key that is not valid base64), now makes every connection fail,
+whatever the value of `failOnUnknownHost`. Previously such a resource was treated as having no entry,
+and such a line was skipped.
+
+As before, a server whose host has entries without a marker is rejected when it presents a key that
+none of them lists, whatever the value of `failOnUnknownHost`, which only applies to a host without
+such an entry: `@revoked` and `@cert-authority` entries do not list a host.
+Check that the `known_hosts` file has an entry for the host name or address, and the port, that each
+endpoint is configured with.
+
=== camel-opensearch - the credentials are only sent to the nodes of the cluster
The `user` and `password` are now offered only to the nodes the client talks to: the hosts of `hostAddresses` and,
diff --git a/docs/user-manual/modules/ROOT/pages/camel-4x-upgrade-guide-4_24.adoc b/docs/user-manual/modules/ROOT/pages/camel-4x-upgrade-guide-4_24.adoc
index 5b1e7e99e39d9..e2d85c0629b4a 100644
--- a/docs/user-manual/modules/ROOT/pages/camel-4x-upgrade-guide-4_24.adoc
+++ b/docs/user-manual/modules/ROOT/pages/camel-4x-upgrade-guide-4_24.adoc
@@ -874,6 +874,35 @@ as `Failed to connect, because a certificate or security check failed`. To resto
The certificate that the user name and password are encrypted with is checked by the same validator. Endpoints that
use the security policy `None` are not affected.
+=== camel-ssh - known_hosts entries are matched as in OpenSSH
+
+When `knownHostsResource` is set, the entries of the `known_hosts` file are now matched as OpenSSH
+matches them, against the host name or address the client connects to, written `host` for port 22 and
+`[host]:port` for another port:
+
+* A host pattern must match that name as a whole. An entry for another host whose name or address
+contains the configured one is no longer used.
+* A pattern such as `db.example.com` only applies to port 22. A server on another port needs an entry
+such as `[db.example.com]:2222`, or a pattern that matches that form, such as `*`.
+* The host is used as it is written: the host configured on the endpoint, or the `HostName` that
+`~/.ssh/config` gives for it. An endpoint configured with a host name no longer uses entries listed
+only under its address, an endpoint configured with an address no longer uses entries listed under the
+name that a reverse lookup of the address returns, and an IPv6 address such as `::1` is matched as
+written instead of in its expanded form.
+* Every entry of the host is considered, so a host can be listed with several keys. Wildcard, negated
+and hashed patterns, comments and `@revoked` entries are supported. `@cert-authority` entries are
+ignored.
+* A `known_hosts` resource that cannot be read, or that has a line that cannot be read (for example an
+unknown `@` marker, a missing key, or a key that is not valid base64), now makes every connection fail,
+whatever the value of `failOnUnknownHost`. Previously such a resource was treated as having no entry,
+and such a line was skipped.
+
+As before, a server whose host has entries without a marker is rejected when it presents a key that
+none of them lists, whatever the value of `failOnUnknownHost`, which only applies to a host without
+such an entry: `@revoked` and `@cert-authority` entries do not list a host.
+Check that the `known_hosts` file has an entry for the host name or address, and the port, that each
+endpoint is configured with.
+
=== camel-zipfile - ZipAggregationStrategy keeps entries with the same name
`ZipAggregationStrategy` replaced an entry of the zip file when a later message had the same entry name, for example
diff --git a/dsl/camel-componentdsl/src/generated/java/org/apache/camel/builder/component/dsl/SshComponentBuilderFactory.java b/dsl/camel-componentdsl/src/generated/java/org/apache/camel/builder/component/dsl/SshComponentBuilderFactory.java
index 19243cffd3263..3665cc88fc718 100644
--- a/dsl/camel-componentdsl/src/generated/java/org/apache/camel/builder/component/dsl/SshComponentBuilderFactory.java
+++ b/dsl/camel-componentdsl/src/generated/java/org/apache/camel/builder/component/dsl/SshComponentBuilderFactory.java
@@ -445,9 +445,12 @@ default SshComponentBuilder ciphers(java.lang.String ciphers) {
/**
- * Specifies whether a connection to an unknown SSH server host key
- * should fail. When false, host keys that are not present in the
- * known_hosts resource are accepted.
+ * Specifies whether a connection to a server whose host has no entry
+ * without a marker in the known_hosts resource should fail. When false,
+ * such a server is accepted. A server whose host has entries without a
+ * marker is always rejected when it presents a key that none of them
+ * lists, and a key listed in a matching revoked entry is always
+ * rejected.
*
* The option is a: <code>boolean</code> type.
*
@@ -516,8 +519,11 @@ default SshComponentBuilder keyType(java.lang.String keyType) {
/**
* Sets the resource path for a known_hosts file used to verify the SSH
- * server host key. When not set, the client does not verify the server
- * host key against a known_hosts file.
+ * server host key. The file uses the OpenSSH known_hosts format, and
+ * its entries are matched against the host and port configured on the
+ * endpoint. A resource that cannot be read, or that has a line that
+ * cannot be read, makes every connection fail. When not set, the client
+ * does not verify the server host key against a known_hosts file.
*
* The option is a: <code>java.lang.String</code> type.
*
diff --git a/dsl/camel-endpointdsl/src/generated/java/org/apache/camel/builder/endpoint/dsl/SshEndpointBuilderFactory.java b/dsl/camel-endpointdsl/src/generated/java/org/apache/camel/builder/endpoint/dsl/SshEndpointBuilderFactory.java
index e83926e90b7f5..c12f8d86ca9df 100644
--- a/dsl/camel-endpointdsl/src/generated/java/org/apache/camel/builder/endpoint/dsl/SshEndpointBuilderFactory.java
+++ b/dsl/camel-endpointdsl/src/generated/java/org/apache/camel/builder/endpoint/dsl/SshEndpointBuilderFactory.java
@@ -628,9 +628,12 @@ default SshEndpointConsumerBuilder ciphers(String ciphers) {
return this;
}
/**
- * Specifies whether a connection to an unknown SSH server host key
- * should fail. When false, host keys that are not present in the
- * known_hosts resource are accepted.
+ * Specifies whether a connection to a server whose host has no entry
+ * without a marker in the known_hosts resource should fail. When false,
+ * such a server is accepted. A server whose host has entries without a
+ * marker is always rejected when it presents a key that none of them
+ * lists, and a key listed in a matching revoked entry is always
+ * rejected.
*
* The option is a: boolean type.
*
@@ -645,9 +648,12 @@ default SshEndpointConsumerBuilder failOnUnknownHost(boolean failOnUnknownHost)
return this;
}
/**
- * Specifies whether a connection to an unknown SSH server host key
- * should fail. When false, host keys that are not present in the
- * known_hosts resource are accepted.
+ * Specifies whether a connection to a server whose host has no entry
+ * without a marker in the known_hosts resource should fail. When false,
+ * such a server is accepted. A server whose host has entries without a
+ * marker is always rejected when it presents a key that none of them
+ * lists, and a key listed in a matching revoked entry is always
+ * rejected.
*
* The option will be converted to a boolean type.
*
@@ -728,8 +734,11 @@ default SshEndpointConsumerBuilder keyType(String keyType) {
}
/**
* Sets the resource path for a known_hosts file used to verify the SSH
- * server host key. When not set, the client does not verify the server
- * host key against a known_hosts file.
+ * server host key. The file uses the OpenSSH known_hosts format, and
+ * its entries are matched against the host and port configured on the
+ * endpoint. A resource that cannot be read, or that has a line that
+ * cannot be read, makes every connection fail. When not set, the client
+ * does not verify the server host key against a known_hosts file.
*
* The option is a: java.lang.String type.
*
@@ -1344,9 +1353,12 @@ default SshEndpointProducerBuilder ciphers(String ciphers) {
return this;
}
/**
- * Specifies whether a connection to an unknown SSH server host key
- * should fail. When false, host keys that are not present in the
- * known_hosts resource are accepted.
+ * Specifies whether a connection to a server whose host has no entry
+ * without a marker in the known_hosts resource should fail. When false,
+ * such a server is accepted. A server whose host has entries without a
+ * marker is always rejected when it presents a key that none of them
+ * lists, and a key listed in a matching revoked entry is always
+ * rejected.
*
* The option is a: boolean type.
*
@@ -1361,9 +1373,12 @@ default SshEndpointProducerBuilder failOnUnknownHost(boolean failOnUnknownHost)
return this;
}
/**
- * Specifies whether a connection to an unknown SSH server host key
- * should fail. When false, host keys that are not present in the
- * known_hosts resource are accepted.
+ * Specifies whether a connection to a server whose host has no entry
+ * without a marker in the known_hosts resource should fail. When false,
+ * such a server is accepted. A server whose host has entries without a
+ * marker is always rejected when it presents a key that none of them
+ * lists, and a key listed in a matching revoked entry is always
+ * rejected.
*
* The option will be converted to a boolean type.
*
@@ -1444,8 +1459,11 @@ default SshEndpointProducerBuilder keyType(String keyType) {
}
/**
* Sets the resource path for a known_hosts file used to verify the SSH
- * server host key. When not set, the client does not verify the server
- * host key against a known_hosts file.
+ * server host key. The file uses the OpenSSH known_hosts format, and
+ * its entries are matched against the host and port configured on the
+ * endpoint. A resource that cannot be read, or that has a line that
+ * cannot be read, makes every connection fail. When not set, the client
+ * does not verify the server host key against a known_hosts file.
*
* The option is a: java.lang.String type.
*
@@ -1953,9 +1971,12 @@ default SshEndpointBuilder ciphers(String ciphers) {
return this;
}
/**
- * Specifies whether a connection to an unknown SSH server host key
- * should fail. When false, host keys that are not present in the
- * known_hosts resource are accepted.
+ * Specifies whether a connection to a server whose host has no entry
+ * without a marker in the known_hosts resource should fail. When false,
+ * such a server is accepted. A server whose host has entries without a
+ * marker is always rejected when it presents a key that none of them
+ * lists, and a key listed in a matching revoked entry is always
+ * rejected.
*
* The option is a: boolean type.
*
@@ -1970,9 +1991,12 @@ default SshEndpointBuilder failOnUnknownHost(boolean failOnUnknownHost) {
return this;
}
/**
- * Specifies whether a connection to an unknown SSH server host key
- * should fail. When false, host keys that are not present in the
- * known_hosts resource are accepted.
+ * Specifies whether a connection to a server whose host has no entry
+ * without a marker in the known_hosts resource should fail. When false,
+ * such a server is accepted. A server whose host has entries without a
+ * marker is always rejected when it presents a key that none of them
+ * lists, and a key listed in a matching revoked entry is always
+ * rejected.
*
* The option will be converted to a boolean type.
*
@@ -2053,8 +2077,11 @@ default SshEndpointBuilder keyType(String keyType) {
}
/**
* Sets the resource path for a known_hosts file used to verify the SSH
- * server host key. When not set, the client does not verify the server
- * host key against a known_hosts file.
+ * server host key. The file uses the OpenSSH known_hosts format, and
+ * its entries are matched against the host and port configured on the
+ * endpoint. A resource that cannot be read, or that has a line that
+ * cannot be read, makes every connection fail. When not set, the client
+ * does not verify the server host key against a known_hosts file.
*
* The option is a: java.lang.String type.
*