Skip to content

Commit 75c03fc

Browse files
committed
Upgrade to support Erlang/OTP 25
Fixes labzero#23
1 parent e99873e commit 75c03fc

File tree

2 files changed

+105
-57
lines changed

2 files changed

+105
-57
lines changed

README.md

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,16 @@ By itself, `:ssh_client_key_api` does not provide SSH functionality, it only add
1616
a way to send private key information to an SSH connection. It is meant to be
1717
used alongside an SSH library such as `:ssh`, `SSHex`, `SSHKit`, or the like.
1818

19+
Note: Upgrade to ssh_client_key_api 0.3.0 or higher for use with Erlang/OTP 25
20+
21+
## Supported Key Types
22+
23+
- rsa - with or without passphrase
24+
- ed25519 - only supported without a passphrase
25+
- ecdsa - only supported without a passphrase
26+
- dsa - with or without passphrase (but DSA keys are [not recommended](https://security.stackexchange.com/a/46781))
27+
- OpenSSH 7.0 and higher no longer accept DSA keys by default
28+
1929
## Installation
2030

2131
The package can be installed by adding `:ssh_client_key_api` to your list of
@@ -35,8 +45,9 @@ end
3545
`with_options/1`. See `with_options/1` for full list of available options.
3646

3747
```elixir
38-
key = File.open!("path/to/keyfile.pem")
39-
known_hosts = File.open!("path/to/known_hosts")
48+
key = File.open!("path/to/id_rsa") # Other key types supported as well
49+
known_hosts = File.open!("path/to/known_hosts", [:read, :write])
50+
4051
cb = SSHClientKeyAPI.with_options(
4152
identity: key,
4253
known_hosts: known_hosts,

lib/ssh_client_key_api.ex

Lines changed: 92 additions & 55 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,20 @@
1+
# Note: Logger.warn exceptions because the :ssh_client_key_api will silently
2+
# catches exceptions
13
defmodule SSHClientKeyAPI do
24
@external_resource "README.md"
35
@moduledoc "README.md"
46
|> File.read!()
57
|> String.split("<!-- MDOC !-->")
68
|> Enum.fetch!(1)
79

8-
alias SSHClientKeyAPI.KeyError
10+
require Logger
11+
require Record
912

1013
@behaviour :ssh_client_key_api
11-
@key_algorithms :ssh.default_algorithms()[:public_key]
1214

1315
@doc """
14-
Returns a tuple suitable for passing the `SSHKit.SSH.connect/2` as the `key_cb` option.
16+
Returns a tuple suitable for passing to `:ssh.connect/3` or
17+
`SSHKit.SSH.connect/2` as the `key_cb` option.
1518
1619
## Options
1720
@@ -35,7 +38,6 @@ defmodule SSHClientKeyAPI do
3538
SSHKit.SSH.connect("example.com", key_cb: cb)
3639
3740
"""
38-
@spec with_options(opts :: list) :: {atom, list}
3941
def with_options(opts \\ []) do
4042
opts = with_defaults(opts)
4143

@@ -47,14 +49,17 @@ defmodule SSHClientKeyAPI do
4749
{__MODULE__, opts}
4850
end
4951

50-
def add_host_key(hostname, key, opts) do
52+
@impl :ssh_client_key_api
53+
def add_host_key(hostname, _port, key, opts) do
54+
hostname = normalize_hostname(hostname)
55+
5156
case silently_accept_hosts(opts) do
5257
true ->
5358
opts
54-
|> known_hosts_data
55-
|> :public_key.ssh_decode(:known_hosts)
59+
|> known_hosts_data()
60+
|> :ssh_file.decode(:known_hosts)
5661
|> (fn decoded -> decoded ++ [{key, [{:hostnames, [hostname]}]}] end).()
57-
|> :public_key.ssh_encode(:known_hosts)
62+
|> :ssh_file.encode(:known_hosts)
5863
|> (fn encoded -> IO.binwrite(known_hosts(opts), encoded) end).()
5964

6065
_ ->
@@ -66,83 +71,115 @@ defmodule SSHClientKeyAPI do
6671

6772
{:error, message}
6873
end
74+
rescue
75+
e ->
76+
Logger.warn("Exception in add_host_key: #{inspect(e)}")
77+
raise e
6978
end
7079

71-
def is_host_key(key, hostname, alg, opts) when alg in @key_algorithms do
72-
silently_accept_hosts(opts) ||
80+
@impl :ssh_client_key_api
81+
def is_host_key(key, host, port, alg, opts) do
82+
:ssh_file.is_host_key(key, host, port, alg, opts)
83+
rescue
84+
e ->
85+
Logger.warn("Exception in is_host_key: #{inspect(e)}")
86+
end
87+
88+
# There's a fundamental disconnect between how the key_cb option works and how
89+
# we want to use it. The key_cb option is expecting us to receive the
90+
# algorithm type and then find the matching key, but we already know the exact
91+
# key we want to use. So instead we return the key for every algorithm type
92+
# and erlang will ignore the keys we return for an incorrect algorithm type.
93+
#
94+
# Ideally we could instead find the matching algorithm type for the key
95+
# provided by the user without requiring the user to manually provide the key
96+
# type but thus far I've been unable to find a way to find the algorithm type
97+
# for the key
98+
@impl :ssh_client_key_api
99+
def user_key(_alg, opts) do
100+
raw_key =
73101
opts
74-
|> known_hosts_data
75-
|> to_string
76-
|> :public_key.ssh_decode(:known_hosts)
77-
|> has_fingerprint(key, hostname)
78-
end
102+
|> identity_data()
103+
|> to_string()
79104

80-
def is_host_key(_, _, alg, _) do
81-
IO.puts("unsupported host key algorithm #{inspect(alg)}")
82-
false
83-
end
84-
85-
def user_key(alg, opts) when alg in @key_algorithms do
86-
opts
87-
|> identity_data
88-
|> to_string
105+
raw_key
89106
|> :public_key.pem_decode()
90107
|> List.first()
91-
|> decode_pem_entry(passphrase(opts))
92-
end
108+
|> case do
109+
{{:no_asn1, :new_openssh}, _data, :not_encrypted} ->
110+
:ssh_file.decode(raw_key, :public_key)
111+
|> case do
112+
[{key, _comments} | _rest] ->
113+
{:ok, key}
93114

94-
def user_key(alg, _) do
95-
raise KeyError, {:unsupported_algorithm, alg}
96-
end
115+
{:error, :key_decode_failed} ->
116+
message =
117+
"unable to decode key, possibly because the key type does not support a passphrase"
97118

98-
defp decode_pem_entry(nil, _phrase) do
99-
raise KeyError, {:unsupported_algorithm, :unknown}
100-
end
119+
Logger.warn(message)
120+
{:error, :key_decode_failed}
101121

102-
defp decode_pem_entry({_type, _data, :not_encrypted} = entry, _) do
103-
{:ok, :public_key.pem_entry_decode(entry)}
104-
end
122+
other ->
123+
Logger.warn("Unexpected return value from :ssh_file.decode/2 #{inspect(other)}")
124+
{:error, :ssh_client_key_api_unable_to_decode_key}
125+
end
105126

106-
defp decode_pem_entry({_type, _data, {alg, _}}, nil) do
107-
raise KeyError, {:passphrase_required, alg}
108-
end
127+
{_type, _data, :not_encrypted} = entry ->
128+
result = :public_key.pem_entry_decode(entry)
109129

110-
defp decode_pem_entry({_type, _data, {alg, _}} = entry, phrase) do
111-
{:ok, :public_key.pem_entry_decode(entry, phrase)}
130+
{:ok, result}
131+
132+
{_type, _data, {_alg, _}} = entry ->
133+
result = :public_key.pem_entry_decode(entry, passphrase(opts))
134+
{:ok, result}
135+
136+
error ->
137+
Logger.warn("Unexpected return value from :public_key.decode/2 #{inspect(error)}")
138+
{:error, :ssh_client_key_api_unable_to_decode_key}
139+
end
112140
rescue
113-
_e in MatchError ->
114-
# credo:disable-for-next-line Credo.Check.Warning.RaiseInsideRescue
115-
raise KeyError, {:incorrect_passphrase, alg}
141+
e ->
142+
Logger.warn("user_key exception: #{inspect(e)}")
143+
raise e
116144
end
117145

118-
defp identity_data(opts) do
119-
cb_opts(opts)[:identity_data]
146+
defp cb_opts(opts) do
147+
opts[:key_cb_private]
120148
end
121149

122-
defp silently_accept_hosts(opts) do
123-
cb_opts(opts)[:silently_accept_hosts]
150+
defp known_hosts_data(opts) do
151+
cb_opts(opts)[:known_hosts_data]
124152
end
125153

126154
defp known_hosts(opts) do
127155
cb_opts(opts)[:known_hosts]
128156
end
129157

130-
defp known_hosts_data(opts) do
131-
cb_opts(opts)[:known_hosts_data]
158+
defp identity_data(opts) do
159+
cb_opts(opts)[:identity_data]
132160
end
133161

134162
defp passphrase(opts) do
135163
cb_opts(opts)[:passphrase]
136-
end
164+
|> case do
165+
# Needs to be a charlist
166+
passphrase when is_list(passphrase) ->
167+
passphrase
137168

138-
defp cb_opts(opts) do
139-
opts[:key_cb_private]
140-
end
169+
passphrase when is_binary(passphrase) ->
170+
Logger.warn("Passphrase must be a charlist, not a binary. Ignoring.")
171+
nil
141172

142-
defp has_fingerprint(fingerprints, key, hostname) do
143-
Enum.any?(fingerprints, fn {k, v} -> k == key && Enum.member?(v[:hostnames], hostname) end)
173+
nil ->
174+
nil
175+
end
144176
end
145177

178+
# Handles the case where the ype of hostname is
179+
# `[inet:ip_address() | inet:hostname()]`
180+
defp normalize_hostname([hostname, _ip_addr]), do: hostname
181+
defp normalize_hostname(hostname), do: hostname
182+
146183
defp default_user_dir, do: Path.join(System.user_home!(), ".ssh")
147184

148185
defp default_identity do

0 commit comments

Comments
 (0)