Some notes on using Go to check and verify SSH host keys
For reasons beyond the scope of this entry, I recently wrote a Go program to verify the SSH host keys of remote machines, using the golang.org/x/crypto/ssh package. In the process of doing this, I found a number of things in the package's documentation to be unclear or worth noting, so here are some notes about it.
In general, you check the server's host key by setting your own
HostKeyCallback
function in your ClientConfig
structure. If you
only want to verify a single host key, you can use FixedHostKey()
, but if
you want to check the server key against a number of them, you'll
need to roll your own callback function. This includes the case
where you have both a RSA and an ed25519 key for the remote server
and you don't necessarily know which one you'll wind up verifying
against.
(You can and should set your preferred order of key types in
HostKeyAlgorithms
in your ClientConfig
, but you may or may not
wish to accept multiple key types if you have them. There are
potential security considerations because of how SSH host key
verification works, and unless
you go well out of your way you'll only verify one server host key.)
Although it's not documented that I can see, the way you compare
two host keys to see if they're the same is to .Marshal()
them
to bytes and then compare the bytes. This is what the code for
FixedHostKey()
does, so I consider it official:
type fixedHostKey struct { key PublicKey } func (f *fixedHostKey) check(hostname string, remote net.Addr, key PublicKey) error { if f.key == nil { return fmt.Errorf("ssh: required host key was nil") } if !bytes.Equal(key.Marshal(), f.key.Marshal()) { return fmt.Errorf("ssh: host key mismatch" } return nil }
In a pleasing display of sanity, your HostKeyCallback
function
is only called after the crypto/ssh package has verified that
the server can authenticate itself with the asserted host key (ie,
that the server knows the corresponding private key).
Unsurprisingly but a bit unfortunately, crypto/ssh does not separate
out the process of using the SSH transport protocol to authenticate the server's host
keys and create the encrypted connection from then trying to use
that encrypted connection to authenticate as a particular user.
This generally means that when you call ssh.NewClientConn()
or
ssh.Dial()
, it's going to fail even if the server's host key is
valid. As a result, you need your HostKeyCallback
function to
save the status of host key verification somewhere where you can
recover it afterward, so you can distinguish between the two errors
of 'server had a bad host key' and 'server did not let us authenticate
with the "none" authentication method'.
(However, you may run into a server that does let you authenticate
and so your call will actually succeed. In that case, remember to
call .Close()
on the SSH Conn
that you wind up with in order
to shut things down neatly; otherwise you'll have some resource
leaks in your Go code.)
Also, note that it's possible for your SSH connection to the server
to fail before it gets to host key authentication and thus to never
have your HostKeyCallback
function get called. For example, the
server might not offer any key types that you've put in your
HostKeyAlgorithms
. As a result, you probably want your HostKeyCallback
function to have to affirmatively set something to signal 'server's
keys passed verification', instead of having it set a 'server's
keys failed verification' flag.
(I almost made this mistake in my own code, which is why I'm bothering to mention it.)
As a cautious sysadmin, it's my view that you shouldn't use
ssh.Dial()
but should instead net.Dial()
the net.Conn
yourself
and then use ssh.NewClientConn()
. The problem with relying on
ssh.Dial()
is that you can't set any sort of timeout for the SSH
authentication process; all you have control over is the timeout
of the initial TCP connection. You probably don't want your check
of SSH host keys to hang if the remote server's SSH daemon is having
a bad day, which does happen from time to time. To avoid this, you
need to call .SetDeadline()
with an appropriate timeout value
on the net.Conn
after it's connected but before you let the
crypto/ssh code take it over.
The crypto/ssh package has a convenient function for iteratively
parsing a known_hosts
file, ssh.ParseKnownHosts()
.
Unfortunately this function is not suitable for production use by
itself, because it completely gives up the moment it encounters
even a single significant error in your known_hosts
file. This
is not how OpenSSH ssh
behaves, for example; by and large ssh
will parse all valid lines and ignore lines with errors. If you
want to duplicate this behavior, you'll need to split your
known_hosts
file up into lines with bytes.Split()
, then feed
each non-blank, non-comment line to ParseKnownHosts
(if you get
an io.EOF
error here, it means 'this line isn't formatted like a
SSH known hosts line'). You'll want to think about what you do
about errors; I accumulate them all, report up to the first N of
them, and then only abort if there's been too many.
(In our case we want to keep going if it looks like we've only made a mistake in a line or two, but if looks like things are badly wrong we're better off giving up entirely.)
Sidebar: Collecting SSH server host keys
If all you want to do is collect SSH server host keys for hosts,
you need a relatively straightforward variation of this process.
You'll repeatedly connect to the server with a different single key
type in HostKeyAlgorithms
each time, and your HostKeyCallback
function will save the host key it gets called with. If I was doing
this, I'd save the host key in its []byte
marshalled form, but
that's probably overkill.
|
|