Repository: pyca/cryptography

Issue:
Add support for loading OpenSSH ECDSA public keys
Continuing from #1531 we should add support for ECDSA public key loading.



Discussion:
Useful references for working on this:

[RFC 5656 Section 3.1](https://tools.ietf.org/html/rfc5656#section-3.1) describes the OpenSSH key format for ECDSA (very similar to RSA / DSA).

[SEC-1 2.3.4](http://www.secg.org/sec1-v2.pdf) defines the algorithm for converting an octet string into an elliptic curve point (x,y). This is referenced as Q in the above RFC.

FWIW, I think we can skip compressed points for now -- I've yet to find an implementation that supports them, neither Go's SSH (actually their core elliptic.Unmarshall function) nor Paramiko support it.

Both those probably (IMO) see a pretty wide range of keys, so starting without it seems fine.


Resolving patch:
diff --git a/src/cryptography/hazmat/primitives/serialization.py b/src/cryptography/hazmat/primitives/serialization.py
--- a/src/cryptography/hazmat/primitives/serialization.py
+++ b/src/cryptography/hazmat/primitives/serialization.py
@@ -7,11 +7,10 @@
 import base64
 import struct
 
+import six
+
 from cryptography.exceptions import UnsupportedAlgorithm
-from cryptography.hazmat.primitives.asymmetric.dsa import (
-    DSAParameterNumbers, DSAPublicNumbers
-)
-from cryptography.hazmat.primitives.asymmetric.rsa import RSAPublicNumbers
+from cryptography.hazmat.primitives.asymmetric import dsa, ec, rsa
 
 
 def load_pem_private_key(data, password, backend):
@@ -30,6 +29,18 @@ def load_ssh_public_key(data, backend):
             'Key is not in the proper format or contains extra data.')
 
     key_type = key_parts[0]
+
+    if key_type == b'ssh-rsa':
+        loader = _load_ssh_rsa_public_key
+    elif key_type == b'ssh-dss':
+        loader = _load_ssh_dss_public_key
+    elif key_type in [
+        b'ecdsa-sha2-nistp256', b'ecdsa-sha2-nistp384', b'ecdsa-sha2-nistp521',
+    ]:
+        loader = _load_ssh_ecdsa_public_key
+    else:
+        raise UnsupportedAlgorithm('Key type is not supported.')
+
     key_body = key_parts[1]
 
     try:
@@ -37,53 +48,81 @@ def load_ssh_public_key(data, backend):
     except TypeError:
         raise ValueError('Key is not in the proper format.')
 
-    if key_type == b'ssh-rsa':
-        return _load_ssh_rsa_public_key(decoded_data, backend)
-    elif key_type == b'ssh-dss':
-        return _load_ssh_dss_public_key(decoded_data, backend)
-    else:
-        raise UnsupportedAlgorithm(
-            'Only RSA and DSA keys are currently supported.'
+    inner_key_type, rest = _read_next_string(decoded_data)
+
+    if inner_key_type != key_type:
+        raise ValueError(
+            'Key header and key body contain different key type values.'
         )
 
+    return loader(key_type, rest, backend)
 
-def _load_ssh_rsa_public_key(decoded_data, backend):
-    key_type, rest = _read_next_string(decoded_data)
-    e, rest = _read_next_mpint(rest)
-    n, rest = _read_next_mpint(rest)
 
-    if key_type != b'ssh-rsa':
-        raise ValueError(
-            'Key header and key body contain different key type values.')
+def _load_ssh_rsa_public_key(key_type, decoded_data, backend):
+    e, rest = _read_next_mpint(decoded_data)
+    n, rest = _read_next_mpint(rest)
 
     if rest:
         raise ValueError('Key body contains extra bytes.')
 
-    return RSAPublicNumbers(e, n).public_key(backend)
+    return rsa.RSAPublicNumbers(e, n).public_key(backend)
 
 
-def _load_ssh_dss_public_key(decoded_data, backend):
-    key_type, rest = _read_next_string(decoded_data)
-    p, rest = _read_next_mpint(rest)
+def _load_ssh_dss_public_key(key_type, decoded_data, backend):
+    p, rest = _read_next_mpint(decoded_data)
     q, rest = _read_next_mpint(rest)
     g, rest = _read_next_mpint(rest)
     y, rest = _read_next_mpint(rest)
 
-    if key_type != b'ssh-dss':
+    if rest:
+        raise ValueError('Key body contains extra bytes.')
+
+    parameter_numbers = dsa.DSAParameterNumbers(p, q, g)
+    public_numbers = dsa.DSAPublicNumbers(y, parameter_numbers)
+
+    return public_numbers.public_key(backend)
+
+
+def _load_ssh_ecdsa_public_key(expected_key_type, decoded_data, backend):
+    curve_name, rest = _read_next_string(decoded_data)
+    data, rest = _read_next_string(rest)
+
+    if expected_key_type != b"ecdsa-sha2-" + curve_name:
         raise ValueError(
-            'Key header and key body contain different key type values.')
+            'Key header and key body contain different key type values.'
+        )
 
     if rest:
         raise ValueError('Key body contains extra bytes.')
 
-    parameter_numbers = DSAParameterNumbers(p, q, g)
-    public_numbers = DSAPublicNumbers(y, parameter_numbers)
+    if curve_name == b"nistp256":
+        curve = ec.SECP256R1()
+    elif curve_name == b"nistp384":
+        curve = ec.SECP384R1()
+    elif curve_name == b"nistp521":
+        curve = ec.SECP521R1()
 
-    return public_numbers.public_key(backend)
+    if six.indexbytes(data, 0) != 4:
+        raise NotImplementedError(
+            "Compressed elliptic curve points are not supported"
+        )
+
+    # key_size is in bits, and sometimes it's not evenly divisible by 8, so we
+    # add 7 to round up the number of bytes.
+    if len(data) != 1 + 2 * ((curve.key_size + 7) // 8):
+        raise ValueError("Malformed key bytes")
+
+    x = _int_from_bytes(data[1:1 + (curve.key_size + 7) // 8], byteorder='big')
+    y = _int_from_bytes(data[1 + (curve.key_size + 7) // 8:], byteorder='big')
+    return ec.EllipticCurvePublicNumbers(x, y, curve).public_key(backend)
 
 
 def _read_next_string(data):
-    """Retrieves the next RFC 4251 string value from the data."""
+    """
+    Retrieves the next RFC 4251 string value from the data.
+
+    While the RFC calls these strings, in Python they are bytes objects.
+    """
     str_len, = struct.unpack('>I', data[:4])
     return data[4:4 + str_len], data[4 + str_len:]
 


Test patch:
diff --git a/tests/hazmat/primitives/test_serialization.py b/tests/hazmat/primitives/test_serialization.py
--- a/tests/hazmat/primitives/test_serialization.py
+++ b/tests/hazmat/primitives/test_serialization.py
@@ -576,7 +576,7 @@ def test_load_bad_encryption_oid_key(self, key_file, password, backend):
 @pytest.mark.requires_backend_interface(interface=RSABackend)
 class TestRSASSHSerialization(object):
     def test_load_ssh_public_key_unsupported(self, backend):
-        ssh_key = b'ecdsa-sha2-nistp256 AAAAE2VjZHNhLXNoYTItbmlzdHAyNTY='
+        ssh_key = b'ecdsa-sha2-junk AAAAE2VjZHNhLXNoYTItbmlzdHAyNTY='
 
         with pytest.raises(UnsupportedAlgorithm):
             load_ssh_public_key(ssh_key, backend)
@@ -784,3 +784,118 @@ def test_load_ssh_public_key_dss(self, backend):
         )
 
         assert numbers == expected
+
+
+@pytest.mark.requires_backend_interface(interface=EllipticCurveBackend)
+class TestECDSASSHSerialization(object):
+    def test_load_ssh_public_key_ecdsa_nist_p256(self, backend):
+        _skip_curve_unsupported(backend, ec.SECP256R1())
+
+        ssh_key = (
+            b"ecdsa-sha2-nistp256 AAAAE2VjZHNhLXNoYTItbmlzdHAyNTYAAAAIbmlzdHAy"
+            b"NTYAAABBBGG2MfkHXp0UkxUyllDzWNBAImsvt5t7pFtTXegZK2WbGxml8zMrgWi5"
+            b"teIg1TO03/FD9hbpBFgBeix3NrCFPls= root@cloud-server-01"
+        )
+        key = load_ssh_public_key(ssh_key, backend)
+        assert isinstance(key, interfaces.EllipticCurvePublicKey)
+
+        expected_x = int(
+            "44196257377740326295529888716212621920056478823906609851236662550"
+            "785814128027", 10
+        )
+        expected_y = int(
+            "12257763433170736656417248739355923610241609728032203358057767672"
+            "925775019611", 10
+        )
+
+        assert key.public_numbers() == ec.EllipticCurvePublicNumbers(
+            expected_x, expected_y, ec.SECP256R1()
+        )
+
+    def test_load_ssh_public_key_ecdsa_nist_p384(self, backend):
+        _skip_curve_unsupported(backend, ec.SECP384R1())
+        ssh_key = (
+            b"ecdsa-sha2-nistp384 AAAAE2VjZHNhLXNoYTItbmlzdHAzODQAAAAIbmlzdHAz"
+            b"ODQAAABhBMzucOm9wbwg4iMr5QL0ya0XNQGXpw4wM5f12E3tWhdcrzyGHyel71t1"
+            b"4bvF9JZ2/WIuSxUr33XDl8jYo+lMQ5N7Vanc7f7i3AR1YydatL3wQfZStQ1I3rBa"
+            b"qQtRSEU8Tg== root@cloud-server-01"
+        )
+        key = load_ssh_public_key(ssh_key, backend)
+
+        expected_x = int(
+            "31541830871345183397582554827482786756220448716666815789487537666"
+            "592636882822352575507883817901562613492450642523901", 10
+        )
+        expected_y = int(
+            "15111413269431823234030344298767984698884955023183354737123929430"
+            "995703524272335782455051101616329050844273733614670", 10
+        )
+
+        assert key.public_numbers() == ec.EllipticCurvePublicNumbers(
+            expected_x, expected_y, ec.SECP384R1()
+        )
+
+    def test_load_ssh_public_key_ecdsa_nist_p521(self, backend):
+        _skip_curve_unsupported(backend, ec.SECP521R1())
+        ssh_key = (
+            b"ecdsa-sha2-nistp521 AAAAE2VjZHNhLXNoYTItbmlzdHA1MjEAAAAIbmlzdHA1"
+            b"MjEAAACFBAGTrRhMSEgF6Ni+PXNz+5fjS4lw3ypUILVVQ0Av+0hQxOx+MyozELon"
+            b"I8NKbrbBjijEs1GuImsmkTmWsMXS1j2A7wB4Kseh7W9KA9IZJ1+TMrzWUEwvOOXi"
+            b"wT23pbaWWXG4NaM7vssWfZBnvz3S174TCXnJ+DSccvWBFnKP0KchzLKxbg== "
+            b"root@cloud-server-01"
+        )
+        key = load_ssh_public_key(ssh_key, backend)
+
+        expected_x = int(
+            "54124123120178189598842622575230904027376313369742467279346415219"
+            "77809037378785192537810367028427387173980786968395921877911964629"
+            "142163122798974160187785455", 10
+        )
+        expected_y = int(
+            "16111775122845033200938694062381820957441843014849125660011303579"
+            "15284560361402515564433711416776946492019498546572162801954089916"
+            "006665939539407104638103918", 10
+        )
+
+        assert key.public_numbers() == ec.EllipticCurvePublicNumbers(
+            expected_x, expected_y, ec.SECP521R1()
+        )
+
+    def test_load_ssh_public_key_ecdsa_nist_p256_trailing_data(self, backend):
+        ssh_key = (
+            b"ecdsa-sha2-nistp256 AAAAE2VjZHNhLXNoYTItbmlzdHAyNTYAAAAIbmlzdHAy"
+            b"NTYAAABBBGG2MfkHXp0UkxUyllDzWNBAImsvt5t7pFtTXegZK2WbGxml8zMrgWi5"
+            b"teIg1TO03/FD9hbpBFgBeix3NrCFPltB= root@cloud-server-01"
+        )
+        with pytest.raises(ValueError):
+            load_ssh_public_key(ssh_key, backend)
+
+    def test_load_ssh_public_key_ecdsa_nist_p256_missing_data(self, backend):
+        ssh_key = (
+            b"ecdsa-sha2-nistp256 AAAAE2VjZHNhLXNoYTItbmlzdHAyNTYAAAAIbmlzdHAy"
+            b"NTYAAABBBGG2MfkHXp0UkxUyllDzWNBAImsvt5t7pFtTXegZK2WbGxml8zMrgWi5"
+            b"teIg1TO03/FD9hbpBFgBeix3NrCF= root@cloud-server-01"
+        )
+        with pytest.raises(ValueError):
+            load_ssh_public_key(ssh_key, backend)
+
+    def test_load_ssh_public_key_ecdsa_nist_p256_compressed(self, backend):
+        # If we ever implement compressed points, note that this is not a valid
+        # one, it just has the compressed marker in the right place.
+        ssh_key = (
+            b"ecdsa-sha2-nistp256 AAAAE2VjZHNhLXNoYTItbmlzdHAyNTYAAAAIbmlzdHAy"
+            b"NTYAAABBAWG2MfkHXp0UkxUyllDzWNBAImsvt5t7pFtTXegZK2WbGxml8zMrgWi5"
+            b"teIg1TO03/FD9hbpBFgBeix3NrCFPls= root@cloud-server-01"
+        )
+        with pytest.raises(NotImplementedError):
+            load_ssh_public_key(ssh_key, backend)
+
+    def test_load_ssh_public_key_ecdsa_nist_p256_bad_curve_name(self, backend):
+        ssh_key = (
+            # The curve name in here is changed to be "nistp255".
+            b"ecdsa-sha2-nistp256 AAAAE2VjZHNhLXNoYTItbmlzdHAyNTYAAAAIbmlzdHAy"
+            b"NTUAAABBBGG2MfkHXp0UkxUyllDzWNBAImsvt5t7pFtTXegZK2WbGxml8zMrgWi5"
+            b"teIg1TO03/FD9hbpBFgBeix3NrCFPls= root@cloud-server-01"
+        )
+        with pytest.raises(ValueError):
+            load_ssh_public_key(ssh_key, backend)