Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

New module_utils openssh #213

Merged
merged 18 commits into from
May 3, 2021

Conversation

Ajpantuso
Copy link
Collaborator

@Ajpantuso Ajpantuso commented Mar 28, 2021

SUMMARY

Utilities for working with OpenSSH key pairs abstracted from cryptography.
The intention is to offer these utilities as an alternative to invoking ssh-keygen.

ISSUE TYPE
  • Feature Pull Request
COMPONENT NAME

plugins/module_utils/crypto/cryptography_openssh.py
tests/unit/plugins/module_utils/crypto/test_cryptography_openssh.py

ADDITIONAL INFORMATION

This exposes two classes, Asymmetric_Keypair for encapsulating asymmetric keypairs supported by cryptography and OpenSSH_Keypiar for the OpenSSH encoded/formatted representation of a keypair, mimicking some of the features offered by ssh-keygen.

Attributes of both classes for the most part are exposed as read-only properties to reinforce that keys are immutable.
However comments applied to OpenSSH formatted public keys can be updated.

Since ed25519 is only available from cryptography >=2.6 the utilities will only successfully import if that condition is met.
A soft requirement is cryptography >=3.0 for OpenSSH formatted private keys, but ssh-keygen is not consistent about formatting private keys this way depending on version either.

Text encoding is set to UTF-8 globally in the module and could be changed to ASCII if neccessary.

SHA256 is used as the standard hash algorithm to align with current ssh-keygen versions.

I removed a couple of unit tests for directly comparing ssh-keygen to cryptography generated keys and vise versa because I wasn't sure how best to implement command invocation outside of an Ansible Module. (Direct comparisons are positive in my local test environment for whatever that is worth)

Definitely looking for input on this one as this is for the most part just a straight encapsulation of the cryptography library with most of the effort spent on how to present it as a utility in this collection.

@Ajpantuso Ajpantuso marked this pull request as ready for review March 28, 2021 21:12
plugins/module_utils/crypto/cryptography_openssh.py Outdated Show resolved Hide resolved
plugins/module_utils/crypto/cryptography_openssh.py Outdated Show resolved Hide resolved
plugins/module_utils/crypto/cryptography_openssh.py Outdated Show resolved Hide resolved
plugins/module_utils/crypto/cryptography_openssh.py Outdated Show resolved Hide resolved
plugins/module_utils/crypto/cryptography_openssh.py Outdated Show resolved Hide resolved
plugins/module_utils/crypto/cryptography_openssh.py Outdated Show resolved Hide resolved
else:
self.__size = self.__algorithm_parameters[self.__keytype]['default_size']

self.__generate_keypair()
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think it would be better to have class methods with speaking names to do these tasks, i.e. Asymmetric_Keypair.load() to load a keypair and Asymmetric_Keypair.generate() to generate one.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Have you thought about this one?

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I did remove _keypair from these method names, but I may be missing the meaning of your suggestion.
Was the intention that these be readable as actions solely in the context of initialization or that the methods also be exposed for end users? I intentionally wanted to present asymmetric keypairs as immutable single-use objects hence why the loading/generating mechanisms are obscured.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I would offer them as class methods, i.e. you can do AsymmetricKeypair.load(filename) to load a keypair, which will return an AsymmetricKeypair instance. This is more expressive then using AsymmetricKeypair(path=filename) IMO.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ok, that does open the possibility of an empty object and makes the faux overloaded constructor less confusing. I will spend some time on that. Thanks

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You could declare the constructor private (by docstring, no idea whether Python allows to do that explicitly without using ugly hacks) to avoid having an empty keypair.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I am considering the possibility of an empty object a benefit.
That would allow for the truthiness of the class to be evaluated in a more obvious (pythonic) way.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hmm, I think it's a lot easier to let only None represent an empty key, instead of having an instance of the class which can represent an empty key. While such an empty value could simplify some cases, it in general makes code more complicated, since it is harder to ensure that a key is actually specified. (For example, it is basically impossible to specify this requirement with the type system, which is IMO a huge disadvantage.)

plugins/module_utils/crypto/cryptography_openssh.py Outdated Show resolved Hide resolved
plugins/module_utils/crypto/cryptography_openssh.py Outdated Show resolved Hide resolved
plugins/module_utils/crypto/cryptography_openssh.py Outdated Show resolved Hide resolved

class Asymmetric_Keypair(object):
def __init__(self, path=None, keytype='rsa', size=None, passphrase=None):
"""Container for newly generated asymmetric keypairs or those loaded from existing files
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This line should be part of the class's docstring, not of the __init__ docstring. It describes what the class does, not what __init__ does. (Same for the other class.)

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ok, generate/load are now exposed as class methods and docstrings are cleaned up.
Updated unit tests to use generate/load as well just for illustration.

@felixfontein felixfontein mentioned this pull request Apr 7, 2021
11 tasks
@felixfontein felixfontein changed the title New module_utils cryptography_openssh New module_utils openssh Apr 7, 2021
@felixfontein
Copy link
Contributor

The change c227733 is not what I meant. It basically reverts the PR to what it was at the beginning (with the class methods thin wrappers around __init__).

The __init__ function should only look like this:

    def __init__(self, keytype, privatekey, publickey, size):
        """
           :keytype: One of rsa, dsa, ecdsa, ed25519
           :privatekey: The cryptography pivate key class
           :publickey: The cryptography public key class
           :size: The key size
        """
        self.__encryption_algorithm = serialization.NoEncryption()
        self.__keytype = keytype
        self.__privatekey = privatekey
        self.__publickey = publickey
        self.__size = size

The 'hard work' should be done by Asymmetric_Keypair.generate and Asymmetric_Keypair.load. They do the work, and once done call __init__ to hand over the generated / loaded data.

@Ajpantuso
Copy link
Collaborator Author

The change c227733 is not what I meant. It basically reverts the PR to what it was at the beginning (with the class methods thin wrappers around __init__).

The __init__ function should only look like this:

    def __init__(self, keytype, privatekey, publickey, size):
        """
           :keytype: One of rsa, dsa, ecdsa, ed25519
           :privatekey: The cryptography pivate key class
           :publickey: The cryptography public key class
           :size: The key size
        """
        self.__encryption_algorithm = serialization.NoEncryption()
        self.__keytype = keytype
        self.__privatekey = privatekey
        self.__publickey = publickey
        self.__size = size

The 'hard work' should be done by Asymmetric_Keypair.generate and Asymmetric_Keypair.load. They do the work, and once done call __init__ to hand over the generated / loaded data.

I was avoiding that because it required re-scoping the helper/utility functions, but the changes are incorporated as of the latest commit.

As a side note, how do I clear this sanity check?
ERROR: plugins/module_utils/openssh/cryptography_openssh.py:87:0: traceback: NameError: name 'padding' is not defined

I didn't change how padding is imported, but previously it was referred to in an instance variable and now it's being referred to in a class variable.

Also the affected sign method which requires padding to be defined works as expected.

@Ajpantuso
Copy link
Collaborator Author

/rebuild_failed

@felixfontein
Copy link
Contributor

/rebuild_failed doesn't work here (since we don't have a bot here), but I've restarted them manually :)

Copy link
Contributor

@felixfontein felixfontein left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sorry it took so long to review this!

elif isinstance(privatekey, ec.EllipticCurvePrivateKey):
keytype = 'ecdsa'
elif isinstance(privatekey, Ed25519PrivateKey):
keytype = 'ed25519'
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This needs a final else block. It should either raise an exception, or set keytype to a concrete value.


:signature: signature to verify
:data: byteslike data signed by the provided signature
"""
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think it is a better API to let verify() raise an exception in case verification fails, and return nothing in case validation succeeds, i.e. do the same thing as cryptography also does.

Comment on lines 380 to 381
@classmethod
def encode_openssh_privatekey(cls, asym_keypair):
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
@classmethod
def encode_openssh_privatekey(cls, asym_keypair):
@staticmethod
def encode_openssh_privatekey(asym_keypair):

Comment on lines 401 to 402
@classmethod
def encode_openssh_publickey(cls, asym_keypair, comment):
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
@classmethod
def encode_openssh_publickey(cls, asym_keypair, comment):
@staticmethod
def encode_openssh_publickey(asym_keypair, comment):

self.__comment = comment

def __eq__(self, other):
return self.fingerprint == other.fingerprint and self.comment == other.comment
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I would rather compare the keys themselves, instead of only comparing the fingerprint. Or are some of the key objects not comparable?

(Also this ignores the passphrase.)

"""

self.__asym_keypair.update_passphrase(passphrase)
self.__openssh_privatekey = type(self).encode_openssh_privatekey(self.__asym_keypair)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
self.__openssh_privatekey = type(self).encode_openssh_privatekey(self.__asym_keypair)
self.__openssh_privatekey = OpenSSH_Keypair.encode_openssh_privatekey(self.__asym_keypair)

@Ajpantuso
Copy link
Collaborator Author

Sorry it took so long to review this!

No worries, this is definitely not a high priority and the reviews are very helpful.

Everything suggested makes sense and I will incorporate the changes.

Regarding the __eq__ method it was definitely the lazy way to do it and yes ed25519 keys have no implementation for comparison as far as I can tell (See here), but for the other key types it's simple to compare on the constituent components of the keys. (See here and here for examples with RSA)

I can investigate more on the special case of ed25519 and probably just ask over on the cryptography side if they have any suggestions.

Thanks.

@felixfontein
Copy link
Contributor

Ah, that reminds me that I already had to solve that problem once ;-) See cryptography_compare_public_keys in plugins/module_utils/crypto/cryptography_support.py. I think comparing public keys suffices as well, since different private keys should not result in the same public key.

@Ajpantuso
Copy link
Collaborator Author

Ah, that reminds me that I already had to solve that problem once ;-) See cryptography_compare_public_keys in plugins/module_utils/crypto/cryptography_support.py. I think comparing public keys suffices as well, since different private keys should not result in the same public key.

Thanks, I made use of your example.
Seems like CI couldn't pull 'coverage' again, but otherwise I think CI should clear.
Besides the suggested changes I also:

  • Stopped encoding passphrase input to bytes to allow the existing validation on the Cryptography side to run naturally
  • Fixed a bug for loading ed25519 keys caused by the private key class not having a key_size attribute
  • Cleaned up some comments
  • Added an explicit test for various comparison scenarios

Copy link
Contributor

@felixfontein felixfontein left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Let's get this merged! 🎉

@felixfontein felixfontein merged commit 37c1540 into ansible-collections:main May 3, 2021
@felixfontein
Copy link
Contributor

@Ajpantuso thanks for contributing this!

@Ajpantuso
Copy link
Collaborator Author

Let's get this merged! 🎉

Thanks for your help!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

2 participants