Skip to content

Commit 050e5c1

Browse files
committed
Add support for OpenSSH "hostkeys" extension
This commit adds client and server support for the OpenSSH "hostkeys" extension, which allows a server to advertise current and future server host keys, so that clients can update their known_hosts to support key rotation and migrating to stronger algorithms over time. This feature is disabled by default, to avoid problems with SSH implementations that don't always gracefully handle unrecognized global requests. To enable it, the option send_server_host_keys can be set to `True` when creating a server or the option server_host_keys_handler can be set to a callable or coroutine when starting a client. This callback will be called with four lists of keys (added, removed, retained, and revoked), representing the differences between what matched in the client's known hosts and what was provided by the server. Note that this feature requires the client-side known hosts checking to be enabled. The callback will only be called if a trusted server host key was matched in the SSH handshake. Thanks go to Matthijs Kooijman for getting me to take another look at this. I had some concerns about not wanting AsyncSSH to ever modify external config files like known_hosts. However, I think the approach chosen here provides the key functionality needed while leaving it up to the application to decide how the configuration should be updated.
1 parent 6d1a99f commit 050e5c1

File tree

2 files changed

+279
-9
lines changed

2 files changed

+279
-9
lines changed

asyncssh/connection.py

Lines changed: 154 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -185,6 +185,10 @@
185185
_Conn = TypeVar('_Conn', bound='SSHConnection')
186186
_Options = TypeVar('_Options', bound='SSHConnectionOptions')
187187

188+
_ServerHostKeysHandler = Optional[Callable[[List[SSHKey], List[SSHKey],
189+
List[SSHKey], List[SSHKey]],
190+
MaybeAwait[None]]]
191+
188192
class _TunnelProtocol(Protocol):
189193
"""Base protocol for connections to tunnel SSH over"""
190194

@@ -1995,6 +1999,11 @@ def send_userauth_success(self) -> None:
19951999
not self._waiter.cancelled():
19962000
self._waiter.set_result(None)
19972001
self._wait = None
2002+
return
2003+
2004+
# This method is only in SSHServerConnection
2005+
# pylint: disable=no-member
2006+
cast(SSHServerConnection, self).send_server_host_keys()
19982007

19992008
def send_channel_open_confirmation(self, send_chan: int, recv_chan: int,
20002009
recv_window: int, recv_pktsize: int,
@@ -2012,6 +2021,13 @@ def send_channel_open_failure(self, send_chan: int, code: int,
20122021
self.send_packet(MSG_CHANNEL_OPEN_FAILURE, UInt32(send_chan),
20132022
UInt32(code), String(reason), String(lang))
20142023

2024+
def _send_global_request(self, request: bytes, *args: bytes,
2025+
want_reply: bool = False) -> None:
2026+
"""Send a global request"""
2027+
2028+
self.send_packet(MSG_GLOBAL_REQUEST, String(request),
2029+
Boolean(want_reply), *args)
2030+
20152031
async def _make_global_request(self, request: bytes,
20162032
*args: bytes) -> Tuple[int, SSHPacket]:
20172033
"""Send a global request and wait for the response"""
@@ -2024,8 +2040,7 @@ async def _make_global_request(self, request: bytes,
20242040

20252041
self._global_request_waiters.append(waiter)
20262042

2027-
self.send_packet(MSG_GLOBAL_REQUEST, String(request),
2028-
Boolean(True), *args)
2043+
self._send_global_request(request, *args, want_reply=True)
20292044

20302045
return await waiter
20312046

@@ -3266,6 +3281,8 @@ def __init__(self, loop: asyncio.AbstractEventLoop,
32663281
self._server_host_key_algs: Optional[Sequence[bytes]] = None
32673282
self._server_host_key: Optional[SSHKey] = None
32683283

3284+
self._server_host_keys_handler = options.server_host_keys_handler
3285+
32693286
self._username = options.username
32703287
self._password = options.password
32713288

@@ -3924,6 +3941,80 @@ def _process_auth_agent_at_openssh_dot_com_open(
39243941
raise ChannelOpenError(OPEN_CONNECT_FAILED,
39253942
'Auth agent forwarding disabled')
39263943

3944+
def _process_hostkeys_00_at_openssh_dot_com_global_request(
3945+
self, packet: SSHPacket) -> None:
3946+
"""Process a list of accepted server host keys"""
3947+
3948+
self.create_task(self._finish_hostkeys(packet))
3949+
3950+
async def _finish_hostkeys(self, packet: SSHPacket) -> None:
3951+
"""Finish processing hostkeys global request"""
3952+
3953+
if not self._server_host_keys_handler:
3954+
self.logger.debug1('Ignoring server host key message: no handler')
3955+
self._report_global_response(False)
3956+
return
3957+
3958+
if self._trusted_host_keys is None:
3959+
self.logger.info('Server host key not verified: handler disabled')
3960+
self._report_global_response(False)
3961+
return
3962+
3963+
added = []
3964+
removed = list(self._trusted_host_keys)
3965+
retained = []
3966+
revoked = []
3967+
prove = []
3968+
3969+
while packet:
3970+
try:
3971+
key_data = packet.get_string()
3972+
key = decode_ssh_public_key(key_data)
3973+
3974+
if key in self._revoked_host_keys:
3975+
revoked.append(key)
3976+
elif key in self._trusted_host_keys:
3977+
retained.append(key)
3978+
removed.remove(key)
3979+
else:
3980+
prove.append((key, String(key_data)))
3981+
except KeyImportError:
3982+
pass
3983+
3984+
if prove:
3985+
pkttype, packet = await self._make_global_request(
3986+
b'hostkeys-prove-00@openssh.com',
3987+
b''.join(key_str for _, key_str in prove))
3988+
3989+
if pkttype == MSG_REQUEST_SUCCESS:
3990+
prefix = String('hostkeys-prove-00@openssh.com') + \
3991+
String(self._session_id)
3992+
3993+
for key, key_str in prove:
3994+
sig = packet.get_string()
3995+
3996+
if key.verify(prefix + key_str, sig):
3997+
added.append(key)
3998+
else:
3999+
self.logger.debug1('Server host key validation failed')
4000+
else:
4001+
self.logger.debug1('Server host key prove request failed')
4002+
4003+
packet.check_end()
4004+
4005+
self.logger.info(f'Server host key report: {len(added)} added, '
4006+
f'{len(removed)} removed, {len(retained)} retained, '
4007+
f'{len(revoked)} revoked')
4008+
4009+
result = self._server_host_keys_handler(added, removed,
4010+
retained, revoked)
4011+
4012+
if inspect.isawaitable(result):
4013+
assert result is not None
4014+
await result
4015+
4016+
self._report_global_response(True)
4017+
39274018
async def attach_x11_listener(self, chan: SSHClientChannel[AnyStr],
39284019
display: Optional[str],
39294020
auth_path: Optional[str],
@@ -5594,6 +5685,7 @@ def __init__(self, loop: asyncio.AbstractEventLoop,
55945685
self._options = options
55955686

55965687
self._server_host_keys = options.server_host_keys
5688+
self._all_server_host_keys = options.all_server_host_keys
55975689
self._server_host_key_algs = list(options.server_host_keys.keys())
55985690
self._known_client_hosts = options.known_client_hosts
55995691
self._trust_client_host = options.trust_client_host
@@ -5719,6 +5811,17 @@ def get_server_host_key(self) -> Optional[SSHKeyPair]:
57195811

57205812
return self._server_host_key
57215813

5814+
def send_server_host_keys(self) -> None:
5815+
"""Send list of available server host keys"""
5816+
5817+
if self._all_server_host_keys:
5818+
self.logger.info('Sending server host keys')
5819+
5820+
keys = [String(key) for key in self._all_server_host_keys.keys()]
5821+
self._send_global_request(b'hostkeys-00@openssh.com', *keys)
5822+
else:
5823+
self.logger.info('Sending server host keys disabled')
5824+
57225825
def gss_kex_auth_supported(self) -> bool:
57235826
"""Return whether GSS key exchange authentication is supported"""
57245827

@@ -6425,6 +6528,26 @@ def _process_tun_at_openssh_dot_com_open(
64256528

64266529
return chan, session
64276530

6531+
def _process_hostkeys_prove_00_at_openssh_dot_com_global_request(
6532+
self, packet: SSHPacket) -> None:
6533+
"""Prove the server has private keys for all requested host keys"""
6534+
6535+
prefix = String('hostkeys-prove-00@openssh.com') + \
6536+
String(self._session_id)
6537+
6538+
signatures = []
6539+
6540+
while packet:
6541+
try:
6542+
key_data = packet.get_string()
6543+
key = self._all_server_host_keys[key_data]
6544+
signatures.append(String(key.sign(prefix + String(key_data))))
6545+
except (KeyError, KeyImportError):
6546+
self._report_global_response(False)
6547+
return
6548+
6549+
self._report_global_response(b''.join(signatures))
6550+
64286551
async def attach_x11_listener(self, chan: SSHServerChannel[AnyStr],
64296552
auth_proto: bytes, auth_data: bytes,
64306553
screen: int) -> Optional[str]:
@@ -7178,6 +7301,17 @@ class SSHClientConnectionOptions(SSHConnectionOptions):
71787301
caution, as it can result in a host key mismatch
71797302
if the client trusts only a subset of the host
71807303
keys the server might return.
7304+
:param server_host_keys_handler: (optional)
7305+
A `callable` or coroutine handler function which if set will be
7306+
called when a global request from the server is received which
7307+
provides an updated list of server host keys. The handler takes
7308+
four arguments (added, removed, retained, and revoked), each of
7309+
which is a list of SSHKey public keys, reflecting differences
7310+
between what the server reported and what is currently matching
7311+
in known_hosts.
7312+
7313+
.. note:: This handler will only be called when known
7314+
host checking is enabled and the check succeeded.
71817315
:param x509_trusted_certs: (optional)
71827316
A list of certificates which should be trusted for X.509 server
71837317
certificate authentication. If no trusted certificates are
@@ -7513,6 +7647,7 @@ class SSHClientConnectionOptions(SSHConnectionOptions):
75137647
:type known_hosts: *see* :ref:`SpecifyingKnownHosts`
75147648
:type host_key_alias: `str`
75157649
:type server_host_key_algs: `str` or `list` of `str`
7650+
:type server_host_keys_handler: `callable` or coroutine
75167651
:type x509_trusted_certs: *see* :ref:`SpecifyingCertificates`
75177652
:type x509_trusted_cert_paths: `list` of `str`
75187653
:type x509_purposes: *see* :ref:`SpecifyingX509Purposes`
@@ -7583,6 +7718,7 @@ class SSHClientConnectionOptions(SSHConnectionOptions):
75837718
known_hosts: KnownHostsArg
75847719
host_key_alias: Optional[str]
75857720
server_host_key_algs: Union[str, Sequence[str]]
7721+
server_host_keys_handler: _ServerHostKeysHandler
75867722
username: str
75877723
password: Optional[str]
75887724
client_host_keysign: Optional[str]
@@ -7650,6 +7786,7 @@ def prepare(self, # type: ignore
76507786
known_hosts: KnownHostsArg = (),
76517787
host_key_alias: DefTuple[Optional[str]] = (),
76527788
server_host_key_algs: _AlgsArg = (),
7789+
server_host_keys_handler: _ServerHostKeysHandler = None,
76537790
username: DefTuple[str] = (), password: Optional[str] = None,
76547791
client_host_keysign: DefTuple[KeySignPath] = (),
76557792
client_host_keys: Optional[_ClientKeysArg] = None,
@@ -7758,6 +7895,8 @@ def prepare(self, # type: ignore
77587895
_select_host_key_algs(server_host_key_algs,
77597896
cast(DefTuple[str], config.get('HostKeyAlgorithms', ())), [])
77607897

7898+
self.server_host_keys_handler = server_host_keys_handler
7899+
77617900
self.username = saslprep(cast(str, username if username != () else
77627901
config.get('User', local_username)))
77637902

@@ -7933,6 +8072,10 @@ class SSHServerConnectionOptions(SSHConnectionOptions):
79338072
:param server_host_certs: (optional)
79348073
A list of optional certificates which can be paired with the
79358074
provided server host keys.
8075+
:param send_server_host_keys: (optional)
8076+
Whether or not to send a list of the allowed server host keys
8077+
for clients to use to update their known hosts like for the
8078+
server.
79368079
:param passphrase: (optional)
79378080
The passphrase to use to decrypt server host keys if they are
79388081
encrypted, or a `callable` or coroutine which takes a filename
@@ -8174,6 +8317,7 @@ class SSHServerConnectionOptions(SSHConnectionOptions):
81748317
:type family: `socket.AF_UNSPEC`, `socket.AF_INET`, or `socket.AF_INET6`
81758318
:type server_host_keys: *see* :ref:`SpecifyingPrivateKeys`
81768319
:type server_host_certs: *see* :ref:`SpecifyingCertificates`
8320+
:type send_server_host_keys: `bool`
81778321
:type passphrase: `str` or `bytes`
81788322
:type known_client_hosts: *see* :ref:`SpecifyingKnownHosts`
81798323
:type trust_client_host: `bool`
@@ -8227,6 +8371,8 @@ class SSHServerConnectionOptions(SSHConnectionOptions):
82278371
server_factory: _ServerFactory
82288372
server_version: bytes
82298373
server_host_keys: 'OrderedDict[bytes, SSHKeyPair]'
8374+
all_server_host_keys: 'OrderedDict[bytes, SSHKeyPair]'
8375+
send_server_host_keys: bool
82308376
known_client_hosts: KnownHostsArg
82318377
trust_client_host: bool
82328378
authorized_client_keys: DefTuple[Optional[SSHAuthorizedKeys]]
@@ -8283,6 +8429,7 @@ def prepare(self, # type: ignore
82838429
keepalive_count_max: DefTuple[int] = (),
82848430
server_host_keys: KeyPairListArg = (),
82858431
server_host_certs: CertListArg = (),
8432+
send_server_host_keys: bool = False,
82868433
passphrase: Optional[BytesOrStr] = None,
82878434
known_client_hosts: KnownHostsArg = None,
82888435
trust_client_host: bool = False,
@@ -8354,14 +8501,15 @@ def prepare(self, # type: ignore
83548501
server_host_certs, loop=loop)
83558502

83568503
self.server_host_keys = OrderedDict()
8504+
self.all_server_host_keys = OrderedDict()
83578505

83588506
for keypair in server_keys:
83598507
for alg in keypair.host_key_algorithms:
8360-
if alg in self.server_host_keys:
8361-
raise ValueError('Multiple keys of type %s found' %
8362-
alg.decode('ascii'))
8508+
if alg not in self.server_host_keys:
8509+
self.server_host_keys[alg] = keypair
83638510

8364-
self.server_host_keys[alg] = keypair
8511+
if send_server_host_keys:
8512+
self.all_server_host_keys[keypair.public_data] = keypair
83658513

83668514
self.known_client_hosts = known_client_hosts
83678515
self.trust_client_host = trust_client_host

0 commit comments

Comments
 (0)