Skip to content

Server: concurrent connections share the host keypair's signature algorithm, so an RSA host key can sign with the wrong one #834

Closed

Description

@zcsizmadia

SSHServerConnection.choose_server_host_key sets the negotiated signature algorithm on the keypair object itself:

for alg in peer_host_key_algs:
    keypair = self._server_host_keys.get(alg)
    if keypair:
        if alg != keypair.algorithm:
            keypair.set_sig_algorithm(alg)

        self._server_host_key = keypair
        return True

(asyncssh/connection.py, still the same on develop at 3d3331c; we hit it with 2.21.0, and 2.24.1 has the same code.)

self._server_host_keys comes from the SSHServerConnectionOptions, which every connection accepted by a listener shares. Its rsa-sha2-256, rsa-sha2-512 and ssh-rsa entries are one keypair object. Key exchange spans several packets, so with two connections on one event loop:

  1. Connection A negotiates rsa-sha2-512; set_sig_algorithm(b'rsa-sha2-512') runs.
  2. Connection B negotiates rsa-sha2-256 before A has signed; set_sig_algorithm(b'rsa-sha2-256') runs on the same object.
  3. A signs its exchange hash with rsa-sha2-256, while its client expects rsa-sha2-512.

The client rightly rejects the signature: OpenSSH reports "incorrect signature", and our client (SshSharp, a .NET SSH client) reports that the signature over the exchange hash is invalid. The connection fails, though nothing is wrong on either side.

How we hit it: in our interop CI, 6 of 100 stress iterations failed against an AsyncSSH server, with several clients connecting at once and preferring different RSA signature algorithms. In every failure, the signature had been made with the other RSA algorithm.

Workaround: our test server wraps choose_server_host_key so that each connection works on a shallow copy of the keypair. set_sig_algorithm only sets instance attributes, so a copy is enough:

_choose_server_host_key = asyncssh.connection.SSHServerConnection.choose_server_host_key

def choose_server_host_key(self, peer_host_key_algs):
    self._server_host_keys = {alg: copy.copy(keypair) for alg, keypair in self._server_host_keys.items()}
    return _choose_server_host_key(self, peer_host_key_algs)

asyncssh.connection.SSHServerConnection.choose_server_host_key = choose_server_host_key

Suggested fix: keep the chosen signature algorithm per connection: copy the keypair when choosing it, or store the algorithm on the connection and pass it to sign.

The client side may have the same pattern: SSHClientConnection._choose_signature_alg calls keypair.set_sig_algorithm(alg) on a client key, and client keys can be shared through a reused SSHClientConnectionOptions. We haven't tested that path.

Activity

  1. ronf commented on Oct 25, 2026

    @ronf
    Owner

    Interesting! In most cases, key pairs would not be shared like this, as a new key object would be created for each key loaded by load_keypairs. However, it could happen if you explicitly loaded a key pair and passed that in as arguments to something like the client or server key lists. In that case, the load_keypairs() function used by the SSH connection options classes could end up using the same keypair object for multiple connections.

    You might want to give the following a try:

    diff --git a/asyncssh/public_key.py b/asyncssh/public_key.py
    index f36111b..7205ac4 100644
    --- a/asyncssh/public_key.py
    +++ b/asyncssh/public_key.py
    @@ -22,6 +22,7 @@
    
     import asyncio
     import binascii
    +import copy
     import inspect
     import os
     import re
    @@ -3647,6 +3648,8 @@ def load_keypairs(
                 cert = SSHX509CertificateChain.construct_from_certs(certs)
    
             if isinstance(key, SSHKeyPair):
    +            key = copy.copy(key)
    +
                 if cert:
                     key.set_certificate(cert)
    

    If this works for you, let me know and I'll include it in the next release.

  2. zcsizmadia commented on Oct 25, 2026

    @zcsizmadia
    ContributorAuthor

    Thanks for looking into this and for the suggestion! It looks like copying in load_keypairs() isn't quite enough: listen() builds the options once, so the copy is still shared by every connection on the listener. I've opened #835, which copies the key per connection instead, with tests. Happy to adjust it however you prefer.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions