From 4b1bf75fb0c06b081da71a03dafcde24ceb7ddc5 Mon Sep 17 00:00:00 2001 From: Jason Axelson Date: Mon, 2 Jan 2023 10:39:19 -1000 Subject: [PATCH] Upgrade to support Erlang/OTP 25 Fixes #23 --- README.md | 15 +++- lib/ssh_client_key_api.ex | 171 ++++++++++++++++++++++---------------- 2 files changed, 111 insertions(+), 75 deletions(-) diff --git a/README.md b/README.md index a746ecf..4ab366a 100644 --- a/README.md +++ b/README.md @@ -16,6 +16,16 @@ By itself, `:ssh_client_key_api` does not provide SSH functionality, it only add a way to send private key information to an SSH connection. It is meant to be used alongside an SSH library such as `:ssh`, `SSHex`, `SSHKit`, or the like. +Note: Upgrade to ssh_client_key_api 0.3.0 or higher for use with Erlang/OTP 25 + +## Supported Key Types + +- rsa - with or without passphrase +- ed25519 - only supported without a passphrase +- ecdsa - only supported without a passphrase +- dsa - with or without passphrase (but DSA keys are [not recommended](https://security.stackexchange.com/a/46781)) + - OpenSSH 7.0 and higher no longer accept DSA keys by default + ## Installation The package can be installed by adding `:ssh_client_key_api` to your list of @@ -35,8 +45,9 @@ end `with_options/1`. See `with_options/1` for full list of available options. ```elixir -key = File.open!("path/to/keyfile.pem") -known_hosts = File.open!("path/to/known_hosts") +key = File.open!("path/to/id_rsa") # Other key types supported as well +known_hosts = File.open!("path/to/known_hosts", [:read, :write]) + cb = SSHClientKeyAPI.with_options( identity: key, known_hosts: known_hosts, diff --git a/lib/ssh_client_key_api.ex b/lib/ssh_client_key_api.ex index 8a605ad..e7a4191 100644 --- a/lib/ssh_client_key_api.ex +++ b/lib/ssh_client_key_api.ex @@ -1,3 +1,5 @@ +# Note: Logger.warn exceptions because the :ssh_client_key_api will silently +# catches exceptions defmodule SSHClientKeyAPI do @external_resource "README.md" @moduledoc "README.md" @@ -5,13 +7,14 @@ defmodule SSHClientKeyAPI do |> String.split("") |> Enum.fetch!(1) - alias SSHClientKeyAPI.KeyError + require Logger + require Record @behaviour :ssh_client_key_api - @key_algorithms :ssh.default_algorithms()[:public_key] @doc """ - Returns a tuple suitable for passing the `SSHKit.SSH.connect/2` as the `key_cb` option. + Returns a tuple suitable for passing to `:ssh.connect/3` or + `SSHKit.SSH.connect/2` as the `key_cb` option. ## Options @@ -35,7 +38,6 @@ defmodule SSHClientKeyAPI do SSHKit.SSH.connect("example.com", key_cb: cb) """ - @spec with_options(opts :: list) :: {atom, list} def with_options(opts \\ []) do opts = with_defaults(opts) @@ -47,102 +49,125 @@ defmodule SSHClientKeyAPI do {__MODULE__, opts} end - def add_host_key(hostname, key, opts) do - case silently_accept_hosts(opts) do - true -> - opts - |> known_hosts_data - |> :public_key.ssh_decode(:known_hosts) - |> (fn decoded -> decoded ++ [{key, [{:hostnames, [hostname]}]}] end).() - |> :public_key.ssh_encode(:known_hosts) - |> (fn encoded -> IO.binwrite(known_hosts(opts), encoded) end).() - - _ -> - message = """ - Error: unknown fingerprint found for #{inspect(hostname)} #{inspect(key)}. - You either need to add a known good fingerprint to your known hosts file for this host, - *or* pass the silently_accept_hosts option to your client key callback - """ - - {:error, message} - end + @impl :ssh_client_key_api + def add_host_key(hostname, _port, key, opts) do + hostname = normalize_hostname(hostname) + + opts + |> known_hosts_data() + |> :ssh_file.decode(:known_hosts) + |> (fn decoded -> decoded ++ [{key, [{:hostnames, [hostname]}]}] end).() + |> :ssh_file.encode(:known_hosts) + |> (fn encoded -> IO.binwrite(known_hosts(opts), encoded) end).() + rescue + e -> + Logger.warn("Exception in add_host_key: #{inspect(e)}") + raise e end - def is_host_key(key, hostname, alg, opts) when alg in @key_algorithms do - silently_accept_hosts(opts) || - opts - |> known_hosts_data - |> to_string - |> :public_key.ssh_decode(:known_hosts) - |> has_fingerprint(key, hostname) + defp known_hosts_data(opts) do + cb_opts(opts)[:known_hosts_data] end - def is_host_key(_, _, alg, _) do - IO.puts("unsupported host key algorithm #{inspect(alg)}") - false + defp known_hosts(opts) do + cb_opts(opts)[:known_hosts] end - def user_key(alg, opts) when alg in @key_algorithms do - opts - |> identity_data - |> to_string + @impl :ssh_client_key_api + def is_host_key(key, host, port, alg, opts) do + :ssh_file.is_host_key(key, host, port, alg, opts) + rescue + e -> + Logger.warn("Exception in is_host_key: #{inspect(e)}") + end + + # There's a fundamental disconnect between how the key_cb option works and how + # we want to use it. The key_cb option is expecting us to receive the + # algorithm type and then find the matching key, but we already know the exact + # key we want to use. So instead we return the key for every algorithm type + # and erlang will ignore the keys we return for an incorrect algorithm type. + # + # Ideally we could instead find the matching algorithm type for the key + # provided by the user without requiring the user to manually provide the key + # type but thus far I've been unable to find a way to find the algorithm type + # for the key + @impl :ssh_client_key_api + def user_key(_alg, opts) do + raw_key = + opts + |> identity_data() + |> to_string() + + raw_key |> :public_key.pem_decode() |> List.first() - |> decode_pem_entry(passphrase(opts)) - end + |> case do + {{:no_asn1, :new_openssh}, _data, :not_encrypted} -> + :ssh_file.decode(raw_key, :public_key) + |> case do + [{key, _comments} | _rest] -> + {:ok, key} - def user_key(alg, _) do - raise KeyError, {:unsupported_algorithm, alg} - end + {:error, :key_decode_failed} -> + message = + "unable to decode key, possibly because the key type does not support a passphrase" - defp decode_pem_entry(nil, _phrase) do - raise KeyError, {:unsupported_algorithm, :unknown} - end + Logger.warn(message) + {:error, :key_decode_failed} - defp decode_pem_entry({_type, _data, :not_encrypted} = entry, _) do - {:ok, :public_key.pem_entry_decode(entry)} - end + other -> + Logger.warn("Unexpected return value from :ssh_file.decode/2 #{inspect(other)}") + {:error, :ssh_client_key_api_unable_to_decode_key} + end - defp decode_pem_entry({_type, _data, {alg, _}}, nil) do - raise KeyError, {:passphrase_required, alg} - end + {_type, _data, :not_encrypted} = entry -> + result = :public_key.pem_entry_decode(entry) - defp decode_pem_entry({_type, _data, {alg, _}} = entry, phrase) do - {:ok, :public_key.pem_entry_decode(entry, phrase)} - rescue - _e in MatchError -> - # credo:disable-for-next-line Credo.Check.Warning.RaiseInsideRescue - raise KeyError, {:incorrect_passphrase, alg} - end + {:ok, result} - defp identity_data(opts) do - cb_opts(opts)[:identity_data] - end + {_type, _data, {_alg, _}} = entry -> + result = :public_key.pem_entry_decode(entry, passphrase(opts)) + {:ok, result} - defp silently_accept_hosts(opts) do - cb_opts(opts)[:silently_accept_hosts] + error -> + Logger.warn("Unexpected return value from :public_key.decode/2 #{inspect(error)}") + {:error, :ssh_client_key_api_unable_to_decode_key} + end + rescue + e -> + Logger.warn("user_key exception: #{inspect(e)}") + raise e end - defp known_hosts(opts) do - cb_opts(opts)[:known_hosts] + defp cb_opts(opts) do + opts[:key_cb_private] end - defp known_hosts_data(opts) do - cb_opts(opts)[:known_hosts_data] + defp identity_data(opts) do + cb_opts(opts)[:identity_data] end defp passphrase(opts) do cb_opts(opts)[:passphrase] - end + |> case do + # Needs to be a charlist + passphrase when is_list(passphrase) -> + passphrase - defp cb_opts(opts) do - opts[:key_cb_private] - end + passphrase when is_binary(passphrase) -> + Logger.warn("Passphrase must be a charlist, not a binary. Ignoring.") + nil - defp has_fingerprint(fingerprints, key, hostname) do - Enum.any?(fingerprints, fn {k, v} -> k == key && Enum.member?(v[:hostnames], hostname) end) + nil -> + nil + end end + # Handles the case where the ype of hostname is + # `[inet:ip_address() | inet:hostname()]` + defp normalize_hostname([hostname, _ip_addr]), do: hostname + defp normalize_hostname(hostname), do: hostname + defp default_user_dir, do: Path.join(System.user_home!(), ".ssh") defp default_identity do