Repository: ojarva/python-sshpubkeys

Issue:
Handle full parsing of authorized_keys file
Hi,

as by #21 would be wonderful to handle full parsing of authorized_keys files, including empty and commented-out lines.

I will try to manage in the future a PR, if it will not happens before.

Thanks,
Daniele

Comments:

Patch:
diff --git a/.isort.cfg b/.isort.cfg
index d82713f..d2f2d02 100644
--- a/.isort.cfg
+++ b/.isort.cfg
@@ -1,3 +1,3 @@
 [settings]
 line_length = 120
-
+not_skip = __init__.py
diff --git a/README.rst b/README.rst
index cc16fe0..1a9c97e 100644
--- a/README.rst
+++ b/README.rst
@@ -59,6 +59,23 @@ Usage:
   print(ssh.options_raw)  # None (string of optional options at the beginning of public key)
   print(ssh.options)  # None (options as a dictionary, parsed and validated)
 
+
+Parsing of `authorized_keys` files:
+
+::
+
+  from sshpubkeys import AuthorizedKeysFile
+
+  key_file = AuthorizedKeysFile("""ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIEGODBKRjsFB/1v3pDRGpA6xR+QpOJg9vat0brlbUNDD\n"""
+             """#This is a comment\n\n"""
+             """ecdsa-sha2-nistp521 AAAAE2VjZHNhLXNoYTItbmlzdHA1MjEAAAAIbmlzdHA1MjEAAACFBAF9QpvUneTvt8"""
+             """lu0ePSuzr7iLE9ZMPu2DFTmqh7BVn89IHuQ5dfg9pArxfHZWgu9lMdlOykVx0I6OXkE35A/mFqwwApyiPmiwno"""
+             """jmRnN//pApl6QQFINHzV/PGOSi599F1Y2tHQwcdb44CPOhkUmHtC9wKazSvw/ivbxNjcMzhhHsWGnA=="""
+             strict=True, disallow_options=True)
+  for key in key_file.keys:
+      print(key.key_type, key.bits, key.hash_512())
+
+
 Options
 -------
 
diff --git a/sshpubkeys/__init__.py b/sshpubkeys/__init__.py
index c361a53..2b7f5c5 100644
--- a/sshpubkeys/__init__.py
+++ b/sshpubkeys/__init__.py
@@ -1,2 +1,2 @@
-from .keys import *  # pylint:disable=wildcard-import
 from .exceptions import *  # pylint:disable=wildcard-import
+from .keys import *  # pylint:disable=wildcard-import
diff --git a/sshpubkeys/keys.py b/sshpubkeys/keys.py
index dad2009..df1a145 100644
--- a/sshpubkeys/keys.py
+++ b/sshpubkeys/keys.py
@@ -27,13 +27,36 @@ from cryptography.hazmat.backends import default_backend
 from cryptography.hazmat.primitives.asymmetric.dsa import DSAParameterNumbers, DSAPublicNumbers
 from cryptography.hazmat.primitives.asymmetric.rsa import RSAPublicNumbers
 
-from .exceptions import *  # pylint:disable=wildcard-import,unused-wildcard-import
+from .exceptions import (InvalidKeyError, InvalidKeyLengthError, InvalidOptionNameError, InvalidOptionsError,
+                         InvalidTypeError, MalformedDataError, MissingMandatoryOptionValueError, TooLongKeyError,
+                         TooShortKeyError, UnknownOptionNameError)
 
-__all__ = ["SSHKey"]
+__all__ = ["AuthorizedKeysFile", "SSHKey"]
+
+
+class AuthorizedKeysFile(object):  # pylint:disable=too-few-public-methods
+    """Represents a full authorized_keys file.
+
+    Comments and empty lines are ignored."""
+
+    def __init__(self, file_obj, **kwargs):
+        self.keys = []
+        self.parse(file_obj, **kwargs)
+
+    def parse(self, file_obj, **kwargs):
+        for line in file_obj:
+            line = line.strip()
+            if not line:
+                continue
+            if line.startswith("#"):
+                continue
+            ssh_key = SSHKey(line, **kwargs)
+            ssh_key.parse()
+            self.keys.append(ssh_key)
 
 
 class SSHKey(object):  # pylint:disable=too-many-instance-attributes
-    """Presents a single SSH keypair.
+    """Represents a single SSH keypair.
 
     ssh_key = SSHKey(key_data, strict=True)
     ssh_key.parse()
@@ -110,6 +133,9 @@ class SSHKey(object):  # pylint:disable=too-many-instance-attributes
             except (InvalidKeyError, NotImplementedError):
                 pass
 
+    def __str__(self):
+        return "Key type: %s, bits: %s, options: %s" % (self.key_type, self.bits, self.options)
+
     def reset(self):
         """Reset all data fields."""
         for field in self.FIELDS:


Test patch:
diff --git a/tests/__init__.py b/tests/__init__.py
index 059b323..9e30401 100644
--- a/tests/__init__.py
+++ b/tests/__init__.py
@@ -4,13 +4,26 @@ New test is generated for each key so that running unittests gives out meaningfu
 
 """
 
+import sys
 import unittest
-from sshpubkeys import SSHKey
+
+from sshpubkeys import AuthorizedKeysFile, InvalidOptionsError, SSHKey
+
+from .authorized_keys import items as list_of_authorized_keys
+from .invalid_authorized_keys import items as list_of_invalid_authorized_keys
+from .invalid_keys import keys as list_of_invalid_keys
+from .invalid_options import options as list_of_invalid_options
 from .valid_keys import keys as list_of_valid_keys
 from .valid_keys_rfc4716 import keys as list_of_valid_keys_rfc4716
-from .invalid_keys import keys as list_of_invalid_keys
 from .valid_options import options as list_of_valid_options
-from .invalid_options import options as list_of_invalid_options
+
+if sys.version_info.major == 2:
+    from io import BytesIO as StringIO
+else:
+    from io import StringIO
+
+
+DEFAULT_KEY = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIEGODBKRjsFB/1v3pDRGpA6xR+QpOJg9vat0brlbUNDD"
 
 
 class TestMisc(unittest.TestCase):
@@ -52,6 +65,32 @@ class TestOptions(unittest.TestCase):
         ssh = SSHKey()
         self.assertRaises(expected_error, ssh.parse_options, option)
 
+    def test_disallow_options(self):
+        ssh = SSHKey(disallow_options=True)
+        key = """command="dump /home",no-pty,no-port-forwarding """ + DEFAULT_KEY
+        self.assertRaises(InvalidOptionsError, ssh.parse, key)
+
+
+class TestAuthorizedKeys(unittest.TestCase):
+
+    def check_valid_file(self, file_str, valid_keys_count):
+        file_obj = StringIO(file_str)
+        key_file = AuthorizedKeysFile(file_obj)
+        for item in key_file.keys:
+            self.assertIsInstance(item, SSHKey)
+        self.assertEqual(len(key_file.keys), valid_keys_count)
+
+    def check_invalid_file(self, file_str, expected_error):
+        file_obj = StringIO(file_str)
+        self.assertRaises(expected_error, AuthorizedKeysFile, file_obj)
+
+    def test_disallow_options(self):
+        file_obj = StringIO("""command="dump /home",no-pty,no-port-forwarding """ + DEFAULT_KEY)
+        self.assertRaises(InvalidOptionsError, AuthorizedKeysFile, file_obj, disallow_options=True)
+        file_obj.seek(0)
+        key_file = AuthorizedKeysFile(file_obj)
+        self.assertEqual(len(key_file.keys), 1)
+
 
 def loop_options(options):
     """ Loop over list of options and dynamically create tests """
@@ -106,11 +145,29 @@ def loop_invalid(keyset, prefix):
             setattr(TestKeys, "test_%s_mode_%s" % (prefix_tmp, mode), ch(pubkey, expected_error, **kwargs))
 
 
+def loop_authorized_keys(keyset):
+    def ch(file_str, valid_keys_count):
+        return lambda self: self.check_valid_file(file_str, valid_keys_count)
+    for i, items in enumerate(keyset):
+        prefix_tmp = "%s_%s" % (items[0], i)
+        setattr(TestAuthorizedKeys, "test_%s" % prefix_tmp, ch(items[1], items[2]))
+
+
+def loop_invalid_authorized_keys(keyset):
+    def ch(file_str, expected_error, **kwargs):
+        return lambda self: self.check_invalid_file(file_str, expected_error, **kwargs)
+    for i, items in enumerate(keyset):
+        prefix_tmp = "%s_%s" % (items[0], i)
+        setattr(TestAuthorizedKeys, "test_invalid_%s" % prefix_tmp, ch(items[1], items[2]))
+
+
 loop_valid(list_of_valid_keys, "valid_key")
 loop_valid(list_of_valid_keys_rfc4716, "valid_key_rfc4716")
 loop_invalid(list_of_invalid_keys, "invalid_key")
 loop_options(list_of_valid_options)
 loop_invalid_options(list_of_invalid_options)
+loop_authorized_keys(list_of_authorized_keys)
+loop_invalid_authorized_keys(list_of_invalid_authorized_keys)
 
 if __name__ == '__main__':
     unittest.main()
diff --git a/tests/authorized_keys.py b/tests/authorized_keys.py
new file mode 100644
index 0000000..a213862
--- /dev/null
+++ b/tests/authorized_keys.py
@@ -0,0 +1,8 @@
+from .valid_keys import keys
+
+items = [
+    ["empty_file", "", 0],
+    ["single_key", keys[0][0], 1],
+    ["comment_only", "# Nothing else than a comment here", 0],
+    ["lines_with_spaces", " # Comments\n  \n" + keys[0][0] + "\n#asdf", 1],
+]
diff --git a/tests/invalid_authorized_keys.py b/tests/invalid_authorized_keys.py
new file mode 100644
index 0000000..a681def
--- /dev/null
+++ b/tests/invalid_authorized_keys.py
@@ -0,0 +1,10 @@
+from sshpubkeys.exceptions import InvalidKeyError, MalformedDataError
+
+from .valid_keys import keys as valid_keys
+
+from.invalid_keys import keys as invalid_keys
+
+items = [
+    ["lines_with_spaces", " # Comments\n  \n" + valid_keys[0][0] + "\nasdf", InvalidKeyError],
+    ["invalid_key", "# Comments\n" + invalid_keys[0][0], MalformedDataError],
+]