Merge pull request #1527 from alex/pr/1517

Added SSH public key loading
diff --git a/AUTHORS.rst b/AUTHORS.rst
index 250b717..c233bc8 100644
--- a/AUTHORS.rst
+++ b/AUTHORS.rst
@@ -18,3 +18,4 @@
 * Matthew Iversen <matt@notevencode.com> (2F04 3DCC D6E6 D5AC D262  2E0B C046 E8A8 7452 2973)
 * Mohammed Attia <skeuomorf@gmail.com> (854A F9C5 9FF5 6E38 B17D 9587 2D70 E1ED 5290 D357)
 * Michael Hart <michael.hart1994@gmail.com>
+* Mark Adams <mark@markadams.me> (A18A 7DD3 283C CF2A B0CE FE0E C7A0 5E3F C972 098C)
diff --git a/CHANGELOG.rst b/CHANGELOG.rst
index b8a799a..cf6d225 100644
--- a/CHANGELOG.rst
+++ b/CHANGELOG.rst
@@ -18,6 +18,10 @@
   :class:`~cryptography.hazmat.primitives.interfaces.CMACContext`.
 * Added support for encoding and decoding :rfc:`6979` signatures in
   :doc:`/hazmat/primitives/asymmetric/utils`.
+* Added
+  :func:`~cryptography.hazmat.primitives.serialization.load_ssh_public_key` to
+  support the loading of OpenSSH public keys (:rfc:`4253`). Currently, only RSA
+  keys are supported.
 
 0.6.1 - 2014-10-15
 ~~~~~~~~~~~~~~~~~~
diff --git a/docs/hazmat/primitives/asymmetric/serialization.rst b/docs/hazmat/primitives/asymmetric/serialization.rst
index b0b37b8..a9392c7 100644
--- a/docs/hazmat/primitives/asymmetric/serialization.rst
+++ b/docs/hazmat/primitives/asymmetric/serialization.rst
@@ -195,3 +195,42 @@
     :raises UnsupportedAlgorithm: If the serialized key is of a type that
         is not supported by the backend or if the key is encrypted with a
         symmetric cipher that is not supported by the backend.
+
+OpenSSH Public Key
+~~~~~~~~~~~~~~~~~~
+
+The format used by OpenSSH to store public keys, as specified in :rfc:`4253`.
+
+Currently, only RSA public keys are supported. Any other type of key will
+result in an exception being thrown.
+
+An example RSA key in OpenSSH format (line breaks added for formatting
+purposes)::
+
+    ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABAQDDu/XRP1kyK6Cgt36gts9XAk
+    FiiuJLW6RU0j3KKVZSs1I7Z3UmU9/9aVh/rZV43WQG8jaR6kkcP4stOR0DEtll
+    PDA7ZRBnrfiHpSQYQ874AZaAoIjgkv7DBfsE6gcDQLub0PFjWyrYQUJhtOLQEK
+    vY/G0vt2iRL3juawWmCFdTK3W3XvwAdgGk71i6lHt+deOPNEPN2H58E4odrZ2f
+    sxn/adpDqfb2sM0kPwQs0aWvrrKGvUaustkivQE4XWiSFnB0oJB/lKK/CKVKuy
+    ///ImSCGHQRvhwariN2tvZ6CBNSLh3iQgeB0AkyJlng7MXB2qYq/Ci2FUOryCX
+    2MzHvnbv testkey@localhost
+
+.. function:: load_ssh_public_key(data, backend)
+
+    .. versionadded:: 0.7
+
+    Deserialize a public key from OpenSSH (:rfc:`4253`) encoded data to an
+    instance of the public key type for the specified backend.
+
+    :param bytes data: The OpenSSH encoded key data.
+
+    :param backend: An
+        :class:`~cryptography.hazmat.backends.interfaces.RSABackend` provider.
+
+    :returns: A new instance of a public key type.
+
+    :raises ValueError: If the OpenSSH data could not be properly decoded or
+        if the key is not in the proper format.
+
+    :raises UnsupportedAlgorithm: If the serialized key is of a type that is
+        not supported.
diff --git a/src/cryptography/hazmat/primitives/serialization.py b/src/cryptography/hazmat/primitives/serialization.py
index b9cf596..0dbbc85 100644
--- a/src/cryptography/hazmat/primitives/serialization.py
+++ b/src/cryptography/hazmat/primitives/serialization.py
@@ -4,9 +4,13 @@
 
 from __future__ import absolute_import, division, print_function
 
+import base64
+import struct
 import warnings
 
 from cryptography import utils
+from cryptography.exceptions import UnsupportedAlgorithm
+from cryptography.hazmat.primitives.asymmetric.rsa import RSAPublicNumbers
 
 
 def load_pem_traditional_openssl_private_key(data, password, backend):
@@ -39,3 +43,76 @@
 
 def load_pem_public_key(data, backend):
     return backend.load_pem_public_key(data)
+
+
+def load_ssh_public_key(data, backend):
+    key_parts = data.split(b' ')
+
+    if len(key_parts) != 2 and len(key_parts) != 3:
+        raise ValueError(
+            'Key is not in the proper format or contains extra data.')
+
+    key_type = key_parts[0]
+    key_body = key_parts[1]
+
+    if not key_type.startswith(b'ssh-'):
+        raise ValueError('SSH-formatted keys must begin with \'ssh-\'.')
+
+    if not key_type.startswith(b'ssh-rsa'):
+        raise UnsupportedAlgorithm('Only RSA keys are currently supported.')
+
+    return _load_ssh_rsa_public_key(key_body, backend)
+
+
+def _load_ssh_rsa_public_key(key_body, backend):
+    data = base64.b64decode(key_body)
+
+    key_type, rest = _read_next_string(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.')
+
+    if rest:
+        raise ValueError('Key body contains extra bytes.')
+
+    return backend.load_rsa_public_numbers(RSAPublicNumbers(e, n))
+
+
+def _read_next_string(data):
+    """Retrieves the next RFC 4251 string value from the data."""
+    str_len, = struct.unpack('>I', data[:4])
+    return data[4:4 + str_len], data[4 + str_len:]
+
+
+def _read_next_mpint(data):
+    """
+    Reads the next mpint from the data.
+
+    Currently, all mpints are interpreted as unsigned.
+    """
+    mpint_data, rest = _read_next_string(data)
+
+    return _int_from_bytes(mpint_data, byteorder='big', signed=False), rest
+
+
+if hasattr(int, "from_bytes"):
+    _int_from_bytes = int.from_bytes
+else:
+    def _int_from_bytes(data, byteorder, signed=False):
+        assert byteorder == 'big'
+        assert not signed
+
+        if len(data) % 4 != 0:
+            data = (b'\x00' * (4 - (len(data) % 4))) + data
+
+        result = 0
+
+        while len(data) > 0:
+            digit, = struct.unpack('>I', data[:4])
+            result = (result << 32) + digit
+            data = data[4:]
+
+        return result
diff --git a/src/cryptography/utils.py b/src/cryptography/utils.py
index 63464df..78f7346 100644
--- a/src/cryptography/utils.py
+++ b/src/cryptography/utils.py
@@ -48,8 +48,9 @@
             )
 
 
-def bit_length(x):
-    if sys.version_info >= (2, 7):
+if sys.version_info >= (2, 7):
+    def bit_length(x):
         return x.bit_length()
-    else:
+else:
+    def bit_length(x):
         return len(bin(x)) - (2 + (x <= 0))
diff --git a/tests/hazmat/primitives/test_serialization.py b/tests/hazmat/primitives/test_serialization.py
index 726e73d..abb5575 100644
--- a/tests/hazmat/primitives/test_serialization.py
+++ b/tests/hazmat/primitives/test_serialization.py
@@ -9,16 +9,17 @@
 
 import pytest
 
-from cryptography.exceptions import _Reasons
+from cryptography.exceptions import UnsupportedAlgorithm, _Reasons
 from cryptography.hazmat.backends.interfaces import (
     EllipticCurveBackend, PEMSerializationBackend, PKCS8SerializationBackend,
-    TraditionalOpenSSLSerializationBackend
+    RSABackend, TraditionalOpenSSLSerializationBackend
 )
 from cryptography.hazmat.primitives import interfaces
 from cryptography.hazmat.primitives.asymmetric import ec
+from cryptography.hazmat.primitives.asymmetric.rsa import RSAPublicNumbers
 from cryptography.hazmat.primitives.serialization import (
     load_pem_pkcs8_private_key, load_pem_private_key, load_pem_public_key,
-    load_pem_traditional_openssl_private_key
+    load_pem_traditional_openssl_private_key, load_ssh_public_key
 )
 
 
@@ -680,3 +681,118 @@
                     pemfile.read().encode(), password, backend
                 )
             )
+
+
+@pytest.mark.requires_backend_interface(interface=RSABackend)
+class TestSSHSerialization(object):
+    def test_load_ssh_public_key_unsupported(self, backend):
+        ssh_key = b'ssh-dss AAAAB3NzaC1kc3MAAACBAO7q0a7VsQZcdRTCqFentQt...'
+
+        with pytest.raises(UnsupportedAlgorithm):
+            load_ssh_public_key(ssh_key, backend)
+
+    def test_load_ssh_public_key_bad_format(self, backend):
+        ssh_key = b'not-a-real-key text'
+
+        with pytest.raises(ValueError):
+            load_ssh_public_key(ssh_key, backend)
+
+    def test_load_ssh_public_key_rsa_too_short(self, backend):
+        ssh_key = b'ssh-rsa'
+
+        with pytest.raises(ValueError):
+            load_ssh_public_key(ssh_key, backend)
+
+    def test_load_ssh_public_key_rsa_key_types_dont_match(self, backend):
+        ssh_key = (
+            b"ssh-bad AAAAB3NzaC1yc2EAAAADAQABAAABAQDDu/XRP1kyK6Cgt36gts9XAk"
+            b"FiiuJLW6RU0j3KKVZSs1I7Z3UmU9/9aVh/rZV43WQG8jaR6kkcP4stOR0DEtll"
+            b"PDA7ZRBnrfiHpSQYQ874AZaAoIjgkv7DBfsE6gcDQLub0PFjWyrYQUJhtOLQEK"
+            b"vY/G0vt2iRL3juawWmCFdTK3W3XvwAdgGk71i6lHt+deOPNEPN2H58E4odrZ2f"
+            b"sxn/adpDqfb2sM0kPwQs0aWvrrKGvUaustkivQE4XWiSFnB0oJB/lKK/CKVKuy"
+            b"///ImSCGHQRvhwariN2tvZ6CBNSLh3iQgeB0AkyJlng7MXB2qYq/Ci2FUOryCX"
+            b"2MzHvnbv testkey@localhost extra"
+        )
+
+        with pytest.raises(ValueError):
+            load_ssh_public_key(ssh_key, backend)
+
+    def test_load_ssh_public_key_rsa_extra_string_after_comment(self, backend):
+        ssh_key = (
+            b"ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABAQDDu/XRP1kyK6Cgt36gts9XAk"
+            b"FiiuJLW6RU0j3KKVZSs1I7Z3UmU9/9aVh/rZV43WQG8jaR6kkcP4stOR0DEtll"
+            b"PDA7ZRBnrfiHpSQYQ874AZaAoIjgkv7DBfsE6gcDQLub0PFjWyrYQUJhtOLQEK"
+            b"vY/G0vt2iRL3juawWmCFdTK3W3XvwAdgGk71i6lHt+deOPNEPN2H58E4odrZ2f"
+            b"sxn/adpDqfb2sM0kPwQs0aWvrrKGvUaustkivQE4XWiSFnB0oJB/lKK/CKVKuy"
+            b"///ImSCGHQRvhwariN2tvZ6CBNSLh3iQgeB0AkyJlng7MXB2qYq/Ci2FUOryCX"
+            # Extra section appended
+            b"2MzHvnbv testkey@localhost extra"
+        )
+
+        with pytest.raises(ValueError):
+            load_ssh_public_key(ssh_key, backend)
+
+    def test_load_ssh_public_key_rsa_extra_data_after_modulo(self, backend):
+        ssh_key = (
+            b"ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABAQDDu/XRP1kyK6Cgt36gts9XAk"
+            b"FiiuJLW6RU0j3KKVZSs1I7Z3UmU9/9aVh/rZV43WQG8jaR6kkcP4stOR0DEtll"
+            b"PDA7ZRBnrfiHpSQYQ874AZaAoIjgkv7DBfsE6gcDQLub0PFjWyrYQUJhtOLQEK"
+            b"vY/G0vt2iRL3juawWmCFdTK3W3XvwAdgGk71i6lHt+deOPNEPN2H58E4odrZ2f"
+            b"sxn/adpDqfb2sM0kPwQs0aWvrrKGvUaustkivQE4XWiSFnB0oJB/lKK/CKVKuy"
+            b"///ImSCGHQRvhwariN2tvZ6CBNSLh3iQgeB0AkyJlng7MXB2qYq/Ci2FUOryCX"
+            b"2MzHvnbvAQ== testkey@localhost"
+        )
+
+        with pytest.raises(ValueError):
+            load_ssh_public_key(ssh_key, backend)
+
+    def test_load_ssh_public_key_rsa_different_string(self, backend):
+        ssh_key = (
+            # "AAAAB3NzA" the final A is capitalized here to cause the string
+            # ssh-rsa inside the base64 encoded blob to be incorrect. It should
+            # be a lower case 'a'.
+            b"ssh-rsa AAAAB3NzAC1yc2EAAAADAQABAAABAQDDu/XRP1kyK6Cgt36gts9XAk"
+            b"FiiuJLW6RU0j3KKVZSs1I7Z3UmU9/9aVh/rZV43WQG8jaR6kkcP4stOR0DEtll"
+            b"PDA7ZRBnrfiHpSQYQ874AZaAoIjgkv7DBfsE6gcDQLub0PFjWyrYQUJhtOLQEK"
+            b"vY/G0vt2iRL3juawWmCFdTK3W3XvwAdgGk71i6lHt+deOPNEPN2H58E4odrZ2f"
+            b"sxn/adpDqfb2sM0kPwQs0aWvrrKGvUaustkivQE4XWiSFnB0oJB/lKK/CKVKuy"
+            b"///ImSCGHQRvhwariN2tvZ6CBNSLh3iQgeB0AkyJlng7MXB2qYq/Ci2FUOryCX"
+            b"2MzHvnbvAQ== testkey@localhost"
+        )
+        with pytest.raises(ValueError):
+            load_ssh_public_key(ssh_key, backend)
+
+    def test_load_ssh_public_key_rsa(self, backend):
+        ssh_key = (
+            b"ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABAQDDu/XRP1kyK6Cgt36gts9XAk"
+            b"FiiuJLW6RU0j3KKVZSs1I7Z3UmU9/9aVh/rZV43WQG8jaR6kkcP4stOR0DEtll"
+            b"PDA7ZRBnrfiHpSQYQ874AZaAoIjgkv7DBfsE6gcDQLub0PFjWyrYQUJhtOLQEK"
+            b"vY/G0vt2iRL3juawWmCFdTK3W3XvwAdgGk71i6lHt+deOPNEPN2H58E4odrZ2f"
+            b"sxn/adpDqfb2sM0kPwQs0aWvrrKGvUaustkivQE4XWiSFnB0oJB/lKK/CKVKuy"
+            b"///ImSCGHQRvhwariN2tvZ6CBNSLh3iQgeB0AkyJlng7MXB2qYq/Ci2FUOryCX"
+            b"2MzHvnbv testkey@localhost"
+        )
+
+        key = load_ssh_public_key(ssh_key, backend)
+
+        assert key is not None
+        assert isinstance(key, interfaces.RSAPublicKey)
+
+        numbers = key.public_numbers()
+
+        expected_e = 0x10001
+        expected_n = int(
+            '00C3BBF5D13F59322BA0A0B77EA0B6CF570241628AE24B5BA454D'
+            '23DCA295652B3523B67752653DFFD69587FAD9578DD6406F23691'
+            'EA491C3F8B2D391D0312D9653C303B651067ADF887A5241843CEF'
+            '8019680A088E092FEC305FB04EA070340BB9BD0F1635B2AD84142'
+            '61B4E2D010ABD8FC6D2FB768912F78EE6B05A60857532B75B75EF'
+            'C007601A4EF58BA947B7E75E38F3443CDD87E7C138A1DAD9D9FB3'
+            '19FF69DA43A9F6F6B0CD243F042CD1A5AFAEB286BD46AEB2D922B'
+            'D01385D6892167074A0907F94A2BF08A54ABB2FFFFC89920861D0'
+            '46F8706AB88DDADBD9E8204D48B87789081E074024C8996783B31'
+            '7076A98ABF0A2D8550EAF2097D8CCC7BE76EF', 16)
+
+        expected = RSAPublicNumbers(expected_e, expected_n)
+
+        assert numbers == expected