Skip to content

Commit

Permalink
Upgrade to support Erlang/OTP 25
Browse files Browse the repository at this point in the history
  • Loading branch information
axelson committed Jan 2, 2023
1 parent e99873e commit 75c03fc
Show file tree
Hide file tree
Showing 2 changed files with 105 additions and 57 deletions.
15 changes: 13 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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,
Expand Down
147 changes: 92 additions & 55 deletions lib/ssh_client_key_api.ex
Original file line number Diff line number Diff line change
@@ -1,17 +1,20 @@
# Note: Logger.warn exceptions because the :ssh_client_key_api will silently
# catches exceptions
defmodule SSHClientKeyAPI do
@external_resource "README.md"
@moduledoc "README.md"
|> File.read!()
|> String.split("<!-- MDOC !-->")
|> 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
Expand All @@ -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)

Expand All @@ -47,14 +49,17 @@ defmodule SSHClientKeyAPI do
{__MODULE__, opts}
end

def add_host_key(hostname, key, opts) do
@impl :ssh_client_key_api
def add_host_key(hostname, _port, key, opts) do
hostname = normalize_hostname(hostname)

case silently_accept_hosts(opts) do
true ->
opts
|> known_hosts_data
|> :public_key.ssh_decode(:known_hosts)
|> known_hosts_data()
|> :ssh_file.decode(:known_hosts)
|> (fn decoded -> decoded ++ [{key, [{:hostnames, [hostname]}]}] end).()
|> :public_key.ssh_encode(:known_hosts)
|> :ssh_file.encode(:known_hosts)
|> (fn encoded -> IO.binwrite(known_hosts(opts), encoded) end).()

_ ->
Expand All @@ -66,83 +71,115 @@ defmodule SSHClientKeyAPI do

{:error, message}
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) ||
@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
|> known_hosts_data
|> to_string
|> :public_key.ssh_decode(:known_hosts)
|> has_fingerprint(key, hostname)
end
|> identity_data()
|> to_string()

def is_host_key(_, _, alg, _) do
IO.puts("unsupported host key algorithm #{inspect(alg)}")
false
end

def user_key(alg, opts) when alg in @key_algorithms do
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)}
{:ok, result}

{_type, _data, {_alg, _}} = entry ->
result = :public_key.pem_entry_decode(entry, passphrase(opts))
{:ok, result}

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 in MatchError ->
# credo:disable-for-next-line Credo.Check.Warning.RaiseInsideRescue
raise KeyError, {:incorrect_passphrase, alg}
e ->
Logger.warn("user_key exception: #{inspect(e)}")
raise e
end

defp identity_data(opts) do
cb_opts(opts)[:identity_data]
defp cb_opts(opts) do
opts[:key_cb_private]
end

defp silently_accept_hosts(opts) do
cb_opts(opts)[:silently_accept_hosts]
defp known_hosts_data(opts) do
cb_opts(opts)[:known_hosts_data]
end

defp known_hosts(opts) do
cb_opts(opts)[:known_hosts]
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
Expand Down

0 comments on commit 75c03fc

Please sign in to comment.