ronf / asyncssh

AsyncSSH is a Python package which provides an asynchronous client and server implementation of the SSHv2 protocol on top of the Python asyncio framework.
Eclipse Public License 2.0
1.56k stars 156 forks source link

Perhaps too strict with OpenSSH key block size? Puttygen generated keys are considered invalid. #684

Closed zEdS15B3GCwq closed 2 months ago

zEdS15B3GCwq commented 2 months ago

Hi,

I've bumped into a problem when I tried to use rsp (a python socks server using asyncssh) with private keys generated by puttygen (0.7.7 and 0.8.1): asyncssh failed with Invalid OpenSSH private key error. I've tried several ed25519 and rsa2048 keys, without protecting password. Keys generated with ssh-keygen work well.

This is the exception trace I got each time:

2024-08-30 12:16:23 ERROR    SSHPool: Got exception while connecting to upstream: Invalid OpenSSH private key
Traceback (most recent call last):
  File "C:\Users\Dev\Virtualenvs\rsp\Lib\site-packages\rsp\ssh_pool.py", line 95, in _build_conn
    options=self._ssh_options()),
            ^^^^^^^^^^^^^^^^^^^
  File "C:\Users\Dev\Virtualenvs\rsp\Lib\site-packages\rsp\__main__.py", line 110, in ssh_options_from_args
    return asyncssh.SSHClientConnectionOptions(**kw)
           ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "C:\Users\Dev\Virtualenvs\rsp\Lib\site-packages\asyncssh\connection.py", line 7102, in __init__
    super().__init__(options=options, last_config=last_config, **kwargs)
  File "C:\Users\Dev\Virtualenvs\rsp\Lib\site-packages\asyncssh\misc.py", line 382, in __init__
    self.prepare(**self.kwargs)
  File "C:\Users\Dev\Virtualenvs\rsp\Lib\site-packages\asyncssh\connection.py", line 8003, in prepare
    load_keypairs(cast(KeyPairListArg, client_keys), passphrase,
  File "C:\Users\Dev\Virtualenvs\rsp\Lib\site-packages\asyncssh\public_key.py", line 3574, in load_keypairs
    key, certs_to_load = read_private_key_and_certs(
                         ^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "C:\Users\Dev\Virtualenvs\rsp\Lib\site-packages\asyncssh\public_key.py", line 3348, in read_private_key_and_certs
    key, cert = import_private_key_and_certs(read_file(filename), passphrase,
                ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "C:\Users\Dev\Virtualenvs\rsp\Lib\site-packages\asyncssh\public_key.py", line 3212, in import_private_key_and_certs
    key, end = _decode_private(data, passphrase,
               ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "C:\Users\Dev\Virtualenvs\rsp\Lib\site-packages\asyncssh\public_key.py", line 2804, in _decode_private
    key = _decode_pem_private(pem_name, headers, data, passphrase,
          ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "C:\Users\Dev\Virtualenvs\rsp\Lib\site-packages\asyncssh\public_key.py", line 2710, in _decode_pem_private
    return _decode_openssh_private(data, passphrase,
           ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "C:\Users\Dev\Virtualenvs\rsp\Lib\site-packages\asyncssh\public_key.py", line 2606, in _decode_openssh_private
    raise KeyImportError('Invalid OpenSSH private key')
asyncssh.public_key.KeyImportError: Invalid OpenSSH private key

The code that throws the exception (line 2606, function _decode_openssh_private in public_key.py):

        if len(pad) >= block_size or pad != bytes(range(1, len(pad) + 1)):
            raise KeyImportError('Invalid OpenSSH private key')

One value of pad I saw was 9 long (bytes 1 to 9), whereas block_size is maximised at 8, so obviously this would fail.

I'm no expert at ssh keys, but a quick search about key block size suggested that 8 is the right value. However, and this is the reason I'm bringing it up here, the key works fine with OpenSSH tools, and it also works fine with asyncssh if block_size is increased to, say, 10. I was able to establish connections with it without any issues. Therefore, even if 8 should be the limit, perhaps your code could be less strict in this aspect, especially since openssh doesn't seem to care and the key works? As I mentioned, I have no knowledge as to how this should work, I'm basing this on a simple observation of different behaviour between your code and others, so it's possible that I'm wrong.

Lastly, here are 2 random keys that failed:

ed25519

-----BEGIN OPENSSH PRIVATE KEY-----
b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAAAMwAAAAtz
c2gtZWQyNTUxOQAAACApEMLTuBBekjWvW7R8PhIvk4imDm1KOePyk3EpPB7jqgAA
AKCwgU5+sIFOfgAAAAtzc2gtZWQyNTUxOQAAACApEMLTuBBekjWvW7R8PhIvk4im
Dm1KOePyk3EpPB7jqgAAAEBwJiB3wR7/sThx4jj1kxMeJ/xEaslgxUBAUCapi0Hz
CSkQwtO4EF6SNa9btHw+Ei+TiKYObUo54/KTcSk8HuOqAAAAEmVkZHNhLWtleS0y
MDI0MDgzMAECAwQFBgcICQoL
-----END OPENSSH PRIVATE KEY-----

rsa2048

-----BEGIN OPENSSH PRIVATE KEY-----
b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAABFwAAAAdz
c2gtcnNhAAAAAwEAAQAAAQEAzRTOXsF++jCXzRIjtwp0jzljedDI7BfGi9J6vYau
Mc4X9HHM+XIXKUanZEmVt5KCRDZCt/njfbTg/NgY2I/rIKV2CmwNLrLvvFPoKXTS
iM2H2Od8HD0t0kvQeiDqIw4aUkR/5oAN/154ZcaNC99XHlxJhn3qXqRmmkjE0kf4
ZLH79Xx8EnX3wHBZVEqsBwf9m33Gs0HiiNRaLsyVJm3vdcJgqOP46FfvkD4h/QUH
Yp2t+KXAQ0vdSCqgC4VvxX8YIZyjEZ8WbpelkHsgFbmamYd+M5AbVPmt42ZmPa7p
tQtxVEQ0taQhEefqm68VJ/SuW+JBZRwDo+ArlDlRn8n7JQAAA9DyA/Iq8gPyKgAA
AAdzc2gtcnNhAAABAQDNFM5ewX76MJfNEiO3CnSPOWN50MjsF8aL0nq9hq4xzhf0
ccz5chcpRqdkSZW3koJENkK3+eN9tOD82BjYj+sgpXYKbA0usu+8U+gpdNKIzYfY
53wcPS3SS9B6IOojDhpSRH/mgA3/Xnhlxo0L31ceXEmGfepepGaaSMTSR/hksfv1
fHwSdffAcFlUSqwHB/2bfcazQeKI1FouzJUmbe91wmCo4/joV++QPiH9BQdina34
pcBDS91IKqALhW/FfxghnKMRnxZul6WQeyAVuZqZh34zkBtU+a3jZmY9rum1C3FU
RDS1pCER5+qbrxUn9K5b4kFlHAOj4CuUOVGfyfslAAAAAwEAAQAAAQEAnTAtXYOC
B9HnCE+3AD0LShv6mwvkdNFkZRdW7J2mNpZ41evD6A4Coqh89OlRz/qDzjSovsrS
Cy/wotwfbx4gYk1wRFy9XeXYysilUeEw3ZprXV1QS3S5Ak8J5BIGZAgTNKs8PTg5
glqufZPJ2Ce/Zo3l7Sfl09S+r9LGyUZQpRxFx7P50EeeFmyDfuQSf+cRU8gJzuVZ
zFqn+P+pYA2IoLXGrMbKlZZUsRwaurbPBXUiq2XBdrSnAbrpObRrt5UmcRodQzhR
M60yOxR8LW74EbphMag9vj7mrMhBnj9nQyaPcdzvv3Fm06U50c/oPGS2J26QR+pA
awWrp9RuR0GwAQAAAIAB31Ox7UiNzB3DAlAAMURRPbwZ2xRY2aH3N6lpZkb1zw3l
tvC4cuwXpW+Nny0vBzBxLyF5I2MIlkOrh2O8SXO0EDQth6rUNBZKPFfXh1NNWb+j
5rl8r9kZO4ZzVaW9rVqXScvLK6UaUKR1hYoZGM5fFYfl+y9ABr0nNYAWWJFjqwAA
AIEA/NLPNh7CDi5OQfyDq2Sege8p6AZ8JEZ8RjtAshvC8tlhGk20QEIKsk7wtwqv
6SlrIXiGztLzwD5+T9eHLInEzqttBy9JO4RB7pCx1ugN8rxWiw95no8uaj/rTWly
QTNIpg5oX0Ae430poCUtd0fab8qIXewl7PZvkqF90r6wRqUAAACBAM+ob91aZa52
jw/S3d4proB4Ny3wVX9hwXLOcmSc4K2v/7agQ92GX52EkfmHOD5UP4Nn5p0kj5LK
y3J3xC1miFpPFXuwUmhpGXdawKVu2NevRVH+fWVgvX/WnOd2lMbTzYtXkltJkExa
6N5X9c2YmyYVi/9TpKB0vMObcQ7IETqBAAAAEHJzYS1rZXktMjAyNDA4MzABAgME
BQYHCAkK
-----END OPENSSH PRIVATE KEY-----
ronf commented 2 months ago

Actually, the block size has a minimum value of 8, not a maximum. In the case of encrypted keys, the block size is generally larger than that. For instance, AES CBC and CTR mode has a block size of 16 and AES GCM has a block size of 12. Some older ciphers had a block size of 8, but these days you'll probably only see that with unencrypted keys. It sounds like that's where you are currently seeing this.

A description of the OpenSSH private key format can be found at https://dnaeon.github.io/openssh-private-key-binary-format/, and there are a bunch of other links at the bottom of that, including the spec at PROTOCOL.key and the actual code in sshkey.c and cipher.c.

Looking at the implementation, OpenSSH shouldn't ever pad to larger than the cipher block size. The spec doesn't really say what the block size should be for the unencrypted case, but you can see in [cipher.c](https://github.com/openssh/openssh-portable/blob/eba523f0a130f1cce829e6aecdcefa841f526a1a/cipher.c#L112 that it sets the block size to 8 in the case of a cipher of "none", so that should match with AsyncSSH. However, OpenSSH doesn't seem to enforce a limit on the allowed amount of padding. So, if another implementation padded by a larger amount than what it would take to get to a block_size boundary, OpenSSH appears to allow this, even though it would never do so itself.

This issue sounded familiar, and it was actually discussed last year in #546. I offered some possible workarounds at the time, but didn't incorporate anything into AsyncSSH to specifically avoid the issue. I could relax the check on the padding, but I'd probably need to add some other defensive code, as discussed in the previous issue.

zEdS15B3GCwq commented 2 months ago

Thanks for the quick response.

First, I'm sorry to have raised the issue again. I didn't check closed issues, a typical mistake if I can say so.

Second, max(something,8) is indeed not maximum 8, but minimum, that's my lack of sleep.

OK, so let's close this since it was already discussed, and thanks for providing asyncssh.

ronf commented 2 months ago

After thinking it over, I looked into what a fix might look like here, and it's actually not too bad. In some ways, it simplifies the code. I still put a hard limit of 255 bytes of padding to avoid having to avoid having to do a modulo operation when generating the padding data to compare against. I think that should be sufficient to solve the problem with PuTTYGen. Here's what I'm looking to check in:

diff --git a/asyncssh/public_key.py b/asyncssh/public_key.py
index 8de2048..11c7e98 100644
--- a/asyncssh/public_key.py
+++ b/asyncssh/public_key.py
@@ -2541,7 +2541,7 @@ def _decode_openssh_private(
                                      'encrypted private keys')

             try:
-                key_size, iv_size, block_size, _, _, _ = \
+                key_size, iv_size, _, _, _, _ = \
                     get_encryption_params(cipher_name)
             except KeyError:
                 raise KeyEncryptionError('Unknown cipher: %s' %
@@ -2579,9 +2579,6 @@ def _decode_openssh_private(
                 raise KeyEncryptionError('Incorrect passphrase')

             key_data = decrypted_key
-            block_size = max(block_size, 8)
-        else:
-            block_size = 8

         packet = SSHPacket(key_data)

@@ -2602,7 +2599,7 @@ def _decode_openssh_private(
         comment = packet.get_string()
         pad = packet.get_remaining_payload()

-        if len(pad) >= block_size or pad != bytes(range(1, len(pad) + 1)):
+        if len(pad) >= 256 or pad != bytes(range(1, len(pad) + 1)):
             raise KeyImportError('Invalid OpenSSH private key')

         if alg == b'ssh-rsa':

It works for me on the two keys you have provided above.

The padding generated when exporting an OpenSSH private key will still use the actual cipher block size, or use 8 bytes for unencrypted keys, matching exactly what is done in OpenSSH and previous versions of AsyncSSH.

zEdS15B3GCwq commented 2 months ago

I've tried the new code with both puttygen and openssh generated ed25519 keys and it works as far as I can tell.

ronf commented 2 months ago

Thanks for the confirmation -- this is now available in the "develop" branch as commit c2599fd and will be included in the next release.

ronf commented 2 months ago

This change is now available in AsyncSSH 2.17.0.