From 92ef50018504527ab103e96040d0656b7d00e41d Mon Sep 17 00:00:00 2001 From: Felix Fontein Date: Tue, 18 Dec 2018 10:07:36 +0100 Subject: [PATCH] openssl_privatekey: add ECC support (#49416) * Add cryptography backend for openssl_privatekey. * Adding ECC support. No support for X25519 and X449, since they don't support serialization. * Improve finterprint calculation to work with Python 3. * Add fingerprint check. * Fix typo. * Use separate curve option for elliptic curves, and use type 'ECC'. * Using curve names as defined in IANA registry. * Bump minimal supported cryptography version. Older versions might work as well, but I couldn't test them. * Improve documentation. --- lib/ansible/module_utils/crypto.py | 37 +- .../modules/crypto/openssl_privatekey.py | 448 ++++++++++++++++-- .../targets/openssl_privatekey/tasks/impl.yml | 144 ++++++ .../targets/openssl_privatekey/tasks/main.yml | 120 +++-- .../openssl_privatekey/tests/validate.yml | 42 +- .../targets/setup_openssl/tasks/main.yml | 1 + 6 files changed, 694 insertions(+), 98 deletions(-) create mode 100644 test/integration/targets/openssl_privatekey/tasks/impl.yml diff --git a/lib/ansible/module_utils/crypto.py b/lib/ansible/module_utils/crypto.py index bb4c030d0e..bad31324e5 100644 --- a/lib/ansible/module_utils/crypto.py +++ b/lib/ansible/module_utils/crypto.py @@ -36,25 +36,44 @@ class OpenSSLObjectError(Exception): pass -def get_fingerprint(path, passphrase=None): - """Generate the fingerprint of the public key. """ +def get_fingerprint_of_bytes(source): + """Generate the fingerprint of the given bytes.""" fingerprint = {} + try: + algorithms = hashlib.algorithms + except AttributeError: + try: + algorithms = hashlib.algorithms_guaranteed + except AttributeError: + return None + + for algo in algorithms: + f = getattr(hashlib, algo) + h = f(source) + try: + # Certain hash functions have a hexdigest() which expects a length parameter + pubkey_digest = h.hexdigest() + except TypeError: + pubkey_digest = h.hexdigest(32) + fingerprint[algo] = ':'.join(pubkey_digest[i:i + 2] for i in range(0, len(pubkey_digest), 2)) + + return fingerprint + + +def get_fingerprint(path, passphrase=None): + """Generate the fingerprint of the public key. """ + privatekey = load_privatekey(path, passphrase) try: publickey = crypto.dump_publickey(crypto.FILETYPE_ASN1, privatekey) - for algo in hashlib.algorithms: - f = getattr(hashlib, algo) - pubkey_digest = f(publickey).hexdigest() - fingerprint[algo] = ':'.join(pubkey_digest[i:i + 2] for i in range(0, len(pubkey_digest), 2)) + return get_fingerprint_of_bytes(publickey) except AttributeError: # If PyOpenSSL < 16.0 crypto.dump_publickey() will fail. # By doing this we prevent the code from raising an error # yet we return no value in the fingerprint hash. - pass - - return fingerprint + return None def load_privatekey(path, passphrase=None): diff --git a/lib/ansible/modules/crypto/openssl_privatekey.py b/lib/ansible/modules/crypto/openssl_privatekey.py index 2b35f556d2..b25ba225f8 100644 --- a/lib/ansible/modules/crypto/openssl_privatekey.py +++ b/lib/ansible/modules/crypto/openssl_privatekey.py @@ -16,15 +16,24 @@ ANSIBLE_METADATA = {'metadata_version': '1.1', DOCUMENTATION = ''' --- module: openssl_privatekey -author: "Yanis Guenane (@Spredzy)" +author: + - "Yanis Guenane (@Spredzy)" + - "Felix Fontein (@felixfontein)" version_added: "2.3" short_description: Generate OpenSSL private keys. description: - - "This module allows one to (re)generate OpenSSL private keys. It uses - the pyOpenSSL python library to interact with openssl. One can generate - either RSA or DSA private keys. Keys are generated in PEM format." + - "This module allows one to (re)generate OpenSSL private keys. One can + generate L(RSA,https://en.wikipedia.org/wiki/RSA_(cryptosystem)), + L(DSA,https://en.wikipedia.org/wiki/Digital_Signature_Algorithm) or + L(ECC,https://en.wikipedia.org/wiki/Elliptic-curve_cryptography) + private keys. Keys are generated in PEM format." + - "The module can use the cryptography Python library, or the pyOpenSSL Python + library. By default, it tries to detect which one is available. This can be + overridden with the I(select_crypto_backend) option." requirements: - - "python-pyOpenSSL" + - "One of the following Python libraries:" + - "cryptography >= 1.2.3 (older versions might work as well)" + - "pyOpenSSL" options: state: required: false @@ -40,9 +49,43 @@ options: type: required: false default: "RSA" - choices: [ RSA, DSA ] + choices: + - RSA + - DSA + - ECC + # - X448 + # - X25519 description: - The algorithm used to generate the TLS/SSL private key + - "Note that C(ECC) requires the C(cryptography) backend. Depending on the curve, you need a newer + version of the cryptography backend." + curve: + required: false + choices: + - secp384r1 + - secp521r1 + - secp224r1 + - secp192r1 + - secp256k1 + - brainpoolP256r1 + - brainpoolP384r1 + - brainpoolP512r1 + - sect571k1 + - sect409k1 + - sect283k1 + - sect233k1 + - sect163k1 + - sect571r1 + - sect409r1 + - sect283r1 + - sect233r1 + - sect163r2 + description: + - Note that not all curves are supported by all versions of C(cryptography). + - For maximal interoperability, C(secp384r1) or C(secp256k1) should be used. + - We use the curve names as defined in the + L(IANA registry for TLS,https://www.iana.org/assignments/tls-parameters/tls-parameters.xhtml#tls-parameters-8). + version_added: "2.8" force: required: false default: False @@ -62,7 +105,24 @@ options: required: false description: - The cipher to encrypt the private key. (cipher can be found by running `openssl list-cipher-algorithms`) + - When using the C(cryptography) backend, use C(auto). version_added: "2.4" + select_crypto_backend: + description: + - "Determines which crypto backend to use. The default choice is C(auto), + which tries to use C(cryptography) if available, and falls back to + C(pyopenssl)." + - "If set to C(pyopenssl), will try to use the L(pyOpenSSL,https://pypi.org/project/pyOpenSSL/) + library." + - "If set to C(cryptography), will try to use the + L(cryptography,https://cryptography.io/) library." + type: str + default: 'auto' + choices: + - auto + - cryptography + - pyopenssl + version_added: "2.8" extends_documentation_fragment: files ''' @@ -105,14 +165,20 @@ type: returned: changed or success type: string sample: RSA +curve: + description: Elliptic curve used to generate the TLS/SSL private key + returned: changed or success, and I(type) is C(ECC) + type: string + sample: secp256k1 filename: description: Path to the generated TLS/SSL private key file returned: changed or success type: string sample: /etc/ssl/private/ansible.com.pem fingerprint: - description: The fingerprint of the public key. Fingerprint will be generated for each hashlib.algorithms available. - Requires PyOpenSSL >= 16.0 for meaningful output. + description: The fingerprint of the public key. Fingerprint will be generated for + each C(hashlib.algorithms) available. + The PyOpenSSL backend requires PyOpenSSL >= 16.0 for meaningful output. returned: changed or success type: dict sample: @@ -124,15 +190,46 @@ fingerprint: sha512: "fd:ed:5e:39:48:5f:9f:fe:7f:25:06:3f:79:08:cd:ee:a5:e7:b3:3d:13:82:87:1f:84:e1:f5:c7:28:77:53:94:86:56:38:69:f0:d9:35:22:01:1e:a6:60:...:0f:9b" ''' +import abc import os import traceback +from distutils.version import LooseVersion + +MINIMAL_PYOPENSSL_VERSION = '0.6' +MINIMAL_CRYPTOGRAPHY_VERSION = '1.2.3' try: + import OpenSSL from OpenSSL import crypto + PYOPENSSL_VERSION = LooseVersion(OpenSSL.__version__) except ImportError: - pyopenssl_found = False + PYOPENSSL_FOUND = False else: - pyopenssl_found = True + PYOPENSSL_FOUND = True +try: + import cryptography + import cryptography.exceptions + import cryptography.hazmat.backends + import cryptography.hazmat.primitives.serialization + import cryptography.hazmat.primitives.asymmetric.rsa + import cryptography.hazmat.primitives.asymmetric.dsa + import cryptography.hazmat.primitives.asymmetric.ec + import cryptography.hazmat.primitives.asymmetric.utils + CRYPTOGRAPHY_VERSION = LooseVersion(cryptography.__version__) +except ImportError: + CRYPTOGRAPHY_FOUND = False +else: + CRYPTOGRAPHY_FOUND = True + try: + import cryptography.hazmat.primitives.asymmetric.x25519 + CRYPTOGRAPHY_HAS_X25519 = True + except ImportError: + CRYPTOGRAPHY_HAS_X25519 = False + try: + import cryptography.hazmat.primitives.asymmetric.x448 + CRYPTOGRAPHY_HAS_X448 = True + except ImportError: + CRYPTOGRAPHY_HAS_X448 = False from ansible.module_utils import crypto as crypto_utils from ansible.module_utils._text import to_native, to_bytes @@ -144,10 +241,10 @@ class PrivateKeyError(crypto_utils.OpenSSLObjectError): pass -class PrivateKey(crypto_utils.OpenSSLObject): +class PrivateKeyBase(crypto_utils.OpenSSLObject): def __init__(self, module): - super(PrivateKey, self).__init__( + super(PrivateKeyBase, self).__init__( module.params['path'], module.params['state'], module.params['force'], @@ -163,21 +260,19 @@ class PrivateKey(crypto_utils.OpenSSLObject): if self.mode is None: self.mode = 0o600 - self.type = crypto.TYPE_RSA - if module.params['type'] == 'DSA': - self.type = crypto.TYPE_DSA + @abc.abstractmethod + def _generate_private_key_data(self): + pass + + @abc.abstractmethod + def _get_fingerprint(self): + pass def generate(self, module): """Generate a keypair.""" if not self.check(module, perms_required=False) or self.force: - self.privatekey = crypto.PKey() - - try: - self.privatekey.generate_key(self.type, self.size) - except (TypeError, ValueError) as exc: - raise PrivateKeyError(exc) - + privatekey_data = self._generate_private_key_data() try: privatekey_file = os.open(self.path, os.O_WRONLY | os.O_CREAT | os.O_TRUNC) os.close(privatekey_file) @@ -192,46 +287,35 @@ class PrivateKey(crypto_utils.OpenSSLObject): module.fail_json(msg="%s" % to_native(e), exception=traceback.format_exc()) os.chmod(self.path, self.mode) privatekey_file = os.open(self.path, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, self.mode) - if self.cipher and self.passphrase: - os.write(privatekey_file, crypto.dump_privatekey(crypto.FILETYPE_PEM, self.privatekey, - self.cipher, to_bytes(self.passphrase))) - else: - os.write(privatekey_file, crypto.dump_privatekey(crypto.FILETYPE_PEM, self.privatekey)) + os.write(privatekey_file, privatekey_data) os.close(privatekey_file) self.changed = True except IOError as exc: self.remove() raise PrivateKeyError(exc) - self.fingerprint = crypto_utils.get_fingerprint(self.path, self.passphrase) + self.fingerprint = self._get_fingerprint() file_args = module.load_file_common_arguments(module.params) if module.set_fs_attributes_if_different(file_args, False): self.changed = True + @abc.abstractmethod + def _check_passphrase(self): + pass + + @abc.abstractmethod + def _check_size_and_type(self): + pass + def check(self, module, perms_required=True): """Ensure the resource is in its desired state.""" - state_and_perms = super(PrivateKey, self).check(module, perms_required) + state_and_perms = super(PrivateKeyBase, self).check(module, perms_required) - def _check_size(privatekey): - return self.size == privatekey.bits() - - def _check_type(privatekey): - return self.type == privatekey.type() - - def _check_passphrase(): - try: - crypto_utils.load_privatekey(self.path, self.passphrase) - return True - except crypto.Error: - return False - - if not state_and_perms or not _check_passphrase(): + if not state_and_perms or not self._check_passphrase(): return False - privatekey = crypto_utils.load_privatekey(self.path, self.passphrase) - - return _check_size(privatekey) and _check_type(privatekey) + return self._check_size_and_type() def dump(self): """Serialize the object into a dictionary.""" @@ -243,6 +327,62 @@ class PrivateKey(crypto_utils.OpenSSLObject): 'fingerprint': self.fingerprint, } + return result + + +# Implementation with using pyOpenSSL +class PrivateKeyPyOpenSSL(PrivateKeyBase): + + def __init__(self, module): + super(PrivateKeyPyOpenSSL, self).__init__(module) + + if module.params['type'] == 'RSA': + self.type = crypto.TYPE_RSA + elif module.params['type'] == 'DSA': + self.type = crypto.TYPE_DSA + else: + module.fail_json(msg="PyOpenSSL backend only supports RSA and DSA keys.") + + def _generate_private_key_data(self): + self.privatekey = crypto.PKey() + + try: + self.privatekey.generate_key(self.type, self.size) + except (TypeError, ValueError) as exc: + raise PrivateKeyError(exc) + + if self.cipher and self.passphrase: + return crypto.dump_privatekey(crypto.FILETYPE_PEM, self.privatekey, + self.cipher, to_bytes(self.passphrase)) + else: + return crypto.dump_privatekey(crypto.FILETYPE_PEM, self.privatekey) + + def _get_fingerprint(self): + return crypto_utils.get_fingerprint(self.path, self.passphrase) + + def _check_passphrase(self): + try: + crypto_utils.load_privatekey(self.path, self.passphrase) + return True + except crypto.Error: + return False + + def _check_size_and_type(self): + def _check_size(privatekey): + return self.size == privatekey.bits() + + def _check_type(privatekey): + return self.type == privatekey.type() + + privatekey = crypto_utils.load_privatekey(self.path, self.passphrase) + + return _check_size(privatekey) and _check_type(privatekey) + + def dump(self): + """Serialize the object into a dictionary.""" + + result = super(PrivateKeyPyOpenSSL, self).dump() + if self.type == crypto.TYPE_RSA: result['type'] = 'RSA' else: @@ -251,26 +391,197 @@ class PrivateKey(crypto_utils.OpenSSLObject): return result +# Implementation with using cryptography +class PrivateKeyCryptography(PrivateKeyBase): + + def _get_ec_class(self, ectype): + ecclass = cryptography.hazmat.primitives.asymmetric.ec.__dict__.get(ectype) + if ecclass is None: + self.module.fail_json(msg='Your cryptography version does not support {0}'.format(ectype)) + return ecclass + + def _add_curve(self, name, ectype, deprecated=False): + def create(size): + ecclass = self._get_ec_class(ectype) + return ecclass() + + def verify(privatekey): + ecclass = self._get_ec_class(ectype) + return isinstance(privatekey.private_numbers().public_numbers.curve, ecclass) + + self.curves[name] = { + 'create': create, + 'verify': verify, + 'deprecated': deprecated, + } + + def __init__(self, module): + super(PrivateKeyCryptography, self).__init__(module) + + self.curves = dict() + self._add_curve('secp384r1', 'SECP384R1') + self._add_curve('secp521r1', 'SECP521R1') + self._add_curve('secp224r1', 'SECP224R1') + self._add_curve('secp192r1', 'SECP192R1') + self._add_curve('secp256k1', 'SECP256K1') + self._add_curve('brainpoolP256r1', 'BrainpoolP256R1', deprecated=True) + self._add_curve('brainpoolP384r1', 'BrainpoolP384R1', deprecated=True) + self._add_curve('brainpoolP512r1', 'BrainpoolP512R1', deprecated=True) + self._add_curve('sect571k1', 'SECT571K1', deprecated=True) + self._add_curve('sect409k1', 'SECT409K1', deprecated=True) + self._add_curve('sect283k1', 'SECT283K1', deprecated=True) + self._add_curve('sect233k1', 'SECT233K1', deprecated=True) + self._add_curve('sect163k1', 'SECT163K1', deprecated=True) + self._add_curve('sect571r1', 'SECT571R1', deprecated=True) + self._add_curve('sect409r1', 'SECT409R1', deprecated=True) + self._add_curve('sect283r1', 'SECT283R1', deprecated=True) + self._add_curve('sect233r1', 'SECT233R1', deprecated=True) + self._add_curve('sect163r2', 'SECT163R2', deprecated=True) + + self.module = module + self.cryptography_backend = cryptography.hazmat.backends.default_backend() + + self.type = module.params['type'] + self.curve = module.params['curve'] + if not CRYPTOGRAPHY_HAS_X25519 and self.type == 'X25519': + self.module.fail_json(msg='Your cryptography version does not support X25519') + if not CRYPTOGRAPHY_HAS_X448 and self.type == 'X448': + self.module.fail_json(msg='Your cryptography version does not support X448') + + def _generate_private_key_data(self): + try: + if self.type == 'RSA': + self.privatekey = cryptography.hazmat.primitives.asymmetric.rsa.generate_private_key( + public_exponent=65537, # OpenSSL always uses this + key_size=self.size, + backend=self.cryptography_backend + ) + if self.type == 'DSA': + self.privatekey = cryptography.hazmat.primitives.asymmetric.dsa.generate_private_key( + key_size=self.size, + backend=self.cryptography_backend + ) + if CRYPTOGRAPHY_HAS_X25519 and self.type == 'X25519': + self.privatekey = cryptography.hazmat.primitives.asymmetric.x25519.X25519PrivateKey.generate() + if CRYPTOGRAPHY_HAS_X448 and self.type == 'X448': + self.privatekey = cryptography.hazmat.primitives.asymmetric.x448.X448PrivateKey.generate() + if self.type == 'ECC' and self.curve in self.curves: + if self.curves[self.curve]['deprecated']: + self.module.warn('Elliptic curves of type {0} should not be used for new keys!'.format(self.curve)) + self.privatekey = cryptography.hazmat.primitives.asymmetric.ec.generate_private_key( + curve=self.curves[self.curve]['create'](self.size), + backend=self.cryptography_backend + ) + except cryptography.exceptions.UnsupportedAlgorithm as e: + self.module.fail_json(msg='Cryptography backend does not support the algorithm required for {0}'.format(self.type)) + + # Select key encryption + encryption_algorithm = cryptography.hazmat.primitives.serialization.NoEncryption() + if self.cipher and self.passphrase: + if self.cipher == 'auto': + encryption_algorithm = cryptography.hazmat.primitives.serialization.BestAvailableEncryption(to_bytes(self.passphrase)) + else: + self.module.fail_json(msg='Cryptography backend can only use "auto" for cipher option.') + + # Serialize key + return self.privatekey.private_bytes( + encoding=cryptography.hazmat.primitives.serialization.Encoding.PEM, + format=cryptography.hazmat.primitives.serialization.PrivateFormat.TraditionalOpenSSL, + encryption_algorithm=encryption_algorithm + ) + + def _load_privatekey(self): + try: + with open(self.path, 'rb') as f: + return cryptography.hazmat.primitives.serialization.load_pem_private_key( + f.read(), + None if self.passphrase is None else to_bytes(self.passphrase), + backend=self.cryptography_backend + ) + except Exception as e: + raise PrivateKeyError(e) + + def _get_fingerprint(self): + # Get bytes of public key + private_key = self._load_privatekey() + public_key = private_key.public_key() + public_key_bytes = public_key.public_bytes( + cryptography.hazmat.primitives.serialization.Encoding.DER, + cryptography.hazmat.primitives.serialization.PublicFormat.SubjectPublicKeyInfo + ) + # Get fingerprints of public_key_bytes + return crypto_utils.get_fingerprint_of_bytes(public_key_bytes) + + def _check_passphrase(self): + try: + self._load_privatekey() + return True + except crypto.Error: + return False + + def _check_size_and_type(self): + privatekey = self._load_privatekey() + + if isinstance(privatekey, cryptography.hazmat.primitives.asymmetric.rsa.RSAPrivateKey): + return self.type == 'RSA' and self.size == privatekey.key_size + if isinstance(privatekey, cryptography.hazmat.primitives.asymmetric.dsa.DSAPrivateKey): + return self.type == 'DSA' and self.size == privatekey.key_size + if CRYPTOGRAPHY_HAS_X25519 and isinstance(privatekey, cryptography.hazmat.primitives.asymmetric.x25519.X25519PrivateKey): + return self.type == 'X25519' + if CRYPTOGRAPHY_HAS_X448 and isinstance(privatekey, cryptography.hazmat.primitives.asymmetric.x448.X448PrivateKey): + return self.type == 'X448' + if isinstance(privatekey, cryptography.hazmat.primitives.asymmetric.ec.EllipticCurvePrivateKey): + if self.type != 'ECC': + return False + if self.curve not in self.curves: + return False + return self.curves[self.curve]['verify'](privatekey) + + return False + + def dump(self): + """Serialize the object into a dictionary.""" + result = super(PrivateKeyCryptography, self).dump() + result['type'] = self.type + if self.type == 'ECC': + result['curve'] = self.curve + return result + + def main(): module = AnsibleModule( argument_spec=dict( state=dict(default='present', choices=['present', 'absent'], type='str'), size=dict(default=4096, type='int'), - type=dict(default='RSA', choices=['RSA', 'DSA'], type='str'), + type=dict(default='RSA', choices=[ + 'RSA', 'DSA', 'ECC', + # x25519 is missing serialization functions: https://github.com/pyca/cryptography/issues/4386 + # x448 is also missing it: https://github.com/pyca/cryptography/pull/4580#issuecomment-437913340 + # 'X448', 'X25519', + ], type='str'), + curve=dict(choices=[ + 'secp384r1', 'secp521r1', 'secp224r1', 'secp192r1', 'secp256k1', + 'brainpoolP256r1', 'brainpoolP384r1', 'brainpoolP512r1', + 'sect571k1', 'sect409k1', 'sect283k1', 'sect233k1', 'sect163k1', + 'sect571r1', 'sect409r1', 'sect283r1', 'sect233r1', 'sect163r2', + ], type='str'), force=dict(default=False, type='bool'), path=dict(required=True, type='path'), passphrase=dict(type='str', no_log=True), cipher=dict(type='str'), + select_crypto_backend=dict(required=False, choices=['auto', 'pyopenssl', 'cryptography'], default='auto', type='str'), ), supports_check_mode=True, add_file_common_args=True, - required_together=[['cipher', 'passphrase']], + required_together=[ + ['cipher', 'passphrase'] + ], + required_if=[ + ['type', 'ECC', ['curve']], + ], ) - if not pyopenssl_found: - module.fail_json(msg='the python pyOpenSSL module is required') - base_dir = os.path.dirname(module.params['path']) if not os.path.isdir(base_dir): module.fail_json( @@ -278,7 +589,40 @@ def main(): msg='The directory %s does not exist or the file is not a directory' % base_dir ) - private_key = PrivateKey(module) + backend = module.params['select_crypto_backend'] + if backend == 'auto': + # Detection what is possible + can_use_cryptography = CRYPTOGRAPHY_FOUND and CRYPTOGRAPHY_VERSION >= LooseVersion(MINIMAL_CRYPTOGRAPHY_VERSION) + can_use_pyopenssl = PYOPENSSL_FOUND and PYOPENSSL_VERSION >= LooseVersion(MINIMAL_PYOPENSSL_VERSION) + + # Decision + if module.params['cipher'] and module.params['passphrase'] and module.params['cipher'] != 'auto': + # First try pyOpenSSL, then cryptography + if can_use_pyopenssl: + backend = 'pyopenssl' + elif can_use_cryptography: + backend = 'cryptography' + else: + # First try cryptography, then pyOpenSSL + if can_use_cryptography: + backend = 'cryptography' + elif can_use_pyopenssl: + backend = 'pyopenssl' + + # Success? + if backend == 'auto': + module.fail_json(msg=('Can detect none of the Python libraries ' + 'cryptography (>= {0}) and pyOpenSSL (>= {1})').format( + MINIMAL_CRYPTOGRAPHY_VERSION, + MINIMAL_PYOPENSSL_VERSION)) + if backend == 'pyopenssl': + if not PYOPENSSL_FOUND: + module.fail_json(msg='The Python pyOpenSSL library is required') + private_key = PrivateKeyPyOpenSSL(module) + elif backend == 'cryptography': + if not CRYPTOGRAPHY_FOUND: + module.fail_json(msg='The Python cryptography library is required') + private_key = PrivateKeyCryptography(module) if private_key.state == 'present': diff --git a/test/integration/targets/openssl_privatekey/tasks/impl.yml b/test/integration/targets/openssl_privatekey/tasks/impl.yml new file mode 100644 index 0000000000..761beaa3a5 --- /dev/null +++ b/test/integration/targets/openssl_privatekey/tasks/impl.yml @@ -0,0 +1,144 @@ +--- +- name: Generate privatekey1 - standard + openssl_privatekey: + path: '{{ output_dir }}/privatekey1.pem' + select_crypto_backend: '{{ select_crypto_backend }}' + +- name: Generate privatekey2 - size 2048 + openssl_privatekey: + path: '{{ output_dir }}/privatekey2.pem' + size: 2048 + select_crypto_backend: '{{ select_crypto_backend }}' + +- name: Generate privatekey3 - type DSA + openssl_privatekey: + path: '{{ output_dir }}/privatekey3.pem' + type: DSA + size: 3072 + select_crypto_backend: '{{ select_crypto_backend }}' + +- name: Generate privatekey4 - standard + openssl_privatekey: + path: '{{ output_dir }}/privatekey4.pem' + select_crypto_backend: '{{ select_crypto_backend }}' + +- name: Delete privatekey4 - standard + openssl_privatekey: + state: absent + path: '{{ output_dir }}/privatekey4.pem' + select_crypto_backend: '{{ select_crypto_backend }}' + +- name: Generate privatekey5 - standard - with passphrase + openssl_privatekey: + path: '{{ output_dir }}/privatekey5.pem' + passphrase: ansible + cipher: "{{ 'aes256' if select_crypto_backend == 'pyopenssl' else 'auto' }}" + select_crypto_backend: '{{ select_crypto_backend }}' + +- name: Generate privatekey5 - standard - idempotence + openssl_privatekey: + path: '{{ output_dir }}/privatekey5.pem' + passphrase: ansible + cipher: "{{ 'aes256' if select_crypto_backend == 'pyopenssl' else 'auto' }}" + select_crypto_backend: '{{ select_crypto_backend }}' + register: privatekey5_idempotence + +- name: Generate privatekey6 - standard - with non-ASCII passphrase + openssl_privatekey: + path: '{{ output_dir }}/privatekey6.pem' + passphrase: ànsïblé + cipher: "{{ 'aes256' if select_crypto_backend == 'pyopenssl' else 'auto' }}" + select_crypto_backend: '{{ select_crypto_backend }}' + +- set_fact: + ecc_types: [] + when: select_crypto_backend == 'pyopenssl' +- set_fact: + ecc_types: + # - curve: X448 + # min_cryptography_version: "2.5" + # - curve: X25519 + # min_cryptography_version: "2.0" + - curve: secp384r1 + openssl_name: secp384r1 + min_cryptography_version: "0.5" + - curve: secp521r1 + openssl_name: secp521r1 + min_cryptography_version: "0.5" + - curve: secp224r1 + openssl_name: secp224r1 + min_cryptography_version: "0.5" + - curve: secp192r1 + openssl_name: prime192v1 + min_cryptography_version: "0.5" + - curve: secp256k1 + openssl_name: secp256k1 + min_cryptography_version: "0.9" + - curve: brainpoolP256r1 + openssl_name: brainpoolP256r1 + min_cryptography_version: "2.2" + - curve: brainpoolP384r1 + openssl_name: brainpoolP384r1 + min_cryptography_version: "2.2" + - curve: brainpoolP512r1 + openssl_name: brainpoolP512r1 + min_cryptography_version: "2.2" + - curve: sect571k1 + openssl_name: sect571k1 + min_cryptography_version: "0.5" + - curve: sect409k1 + openssl_name: sect409k1 + min_cryptography_version: "0.5" + - curve: sect283k1 + openssl_name: sect283k1 + min_cryptography_version: "0.5" + - curve: sect233k1 + openssl_name: sect233k1 + min_cryptography_version: "0.5" + - curve: sect163k1 + openssl_name: sect163k1 + min_cryptography_version: "0.5" + - curve: sect571r1 + openssl_name: sect571r1 + min_cryptography_version: "0.5" + - curve: sect409r1 + openssl_name: sect409r1 + min_cryptography_version: "0.5" + - curve: sect283r1 + openssl_name: sect283r1 + min_cryptography_version: "0.5" + - curve: sect233r1 + openssl_name: sect233r1 + min_cryptography_version: "0.5" + - curve: sect163r2 + openssl_name: sect163r2 + min_cryptography_version: "0.5" + when: select_crypto_backend == 'cryptography' + +- name: Test ECC key generation + openssl_privatekey: + path: '{{ output_dir }}/privatekey-{{ item.curve }}.pem' + type: ECC + curve: "{{ item.curve }}" + select_crypto_backend: '{{ select_crypto_backend }}' + when: | + cryptography_version.stdout is version(item.min_cryptography_version, '>=') and + item.openssl_name in openssl_ecc_list + loop: "{{ ecc_types }}" + loop_control: + label: "{{ item.curve }}" + register: privatekey_ecc_generate + +- name: Test ECC key generation (idempotency) + openssl_privatekey: + path: '{{ output_dir }}/privatekey-{{ item.curve }}.pem' + type: ECC + curve: "{{ item.curve }}" + select_crypto_backend: '{{ select_crypto_backend }}' + when: | + cryptography_version.stdout is version(item.min_cryptography_version, '>=') and + item.openssl_name in openssl_ecc_list + loop: "{{ ecc_types }}" + loop_control: + label: "{{ item.curve }}" + register: privatekey_ecc_idempotency diff --git a/test/integration/targets/openssl_privatekey/tasks/main.yml b/test/integration/targets/openssl_privatekey/tasks/main.yml index d33134badd..5bf6dd92c8 100644 --- a/test/integration/targets/openssl_privatekey/tasks/main.yml +++ b/test/integration/targets/openssl_privatekey/tasks/main.yml @@ -1,43 +1,95 @@ -- name: Generate privatekey1 - standard - openssl_privatekey: - path: '{{ output_dir }}/privatekey1.pem' +--- +- name: Find out which elliptic curves are supported by installed OpenSSL + command: openssl ecparam -list_curves + register: openssl_ecc -- name: Generate privatekey2 - size 2048 - openssl_privatekey: - path: '{{ output_dir }}/privatekey2.pem' - size: 2048 +- name: Compile list of elliptic curves supported by OpenSSL + set_fact: + openssl_ecc_list: | + {{ + openssl_ecc.stdout_lines + | map('regex_search', '^ *([a-zA-Z0-9_-]+) *: .*$') + | select() + | map('regex_replace', '^ *([a-zA-Z0-9_-]+) *: .*$', '\1') + | list + }} + when: ansible_distribution != 'CentOS' or ansible_distribution_major_version != '6' + # CentOS comes with a very old jinja2 which does not include the map() filter... +- name: Compile list of elliptic curves supported by OpenSSL (CentOS 6) + set_fact: + openssl_ecc_list: + - secp384r1 + - secp521r1 + - prime256v1 + when: ansible_distribution == 'CentOS' and ansible_distribution_major_version == '6' -- name: Generate privatekey3 - type DSA - openssl_privatekey: - path: '{{ output_dir }}/privatekey3.pem' - type: DSA +- name: List of elliptic curves supported by OpenSSL + debug: var=openssl_ecc_list -- name: Generate privatekey4 - standard - openssl_privatekey: - path: '{{ output_dir }}/privatekey4.pem' +- block: + - name: Running tests with pyOpenSSL backend + include_tasks: impl.yml + vars: + select_crypto_backend: pyopenssl -- name: Delete privatekey4 - standard - openssl_privatekey: + - import_tasks: ../tests/validate.yml + + # FIXME: minimal pyOpenSSL version?! + when: pyopenssl_version.stdout is version('0.6', '>=') + +- name: Remove output directory + file: + path: "{{ output_dir }}" state: absent - path: '{{ output_dir }}/privatekey4.pem' -- name: Generate privatekey5 - standard - with passphrase - openssl_privatekey: - path: '{{ output_dir }}/privatekey5.pem' - passphrase: ansible - cipher: aes256 +- name: Re-create output directory + file: + path: "{{ output_dir }}" + state: directory -- name: Generate privatekey5 - standard - idempotence - openssl_privatekey: - path: '{{ output_dir }}/privatekey5.pem' - passphrase: ansible - cipher: aes256 - register: privatekey5_idempotence +- block: + - name: Running tests with cryptography backend + include_tasks: impl.yml + vars: + select_crypto_backend: cryptography -- name: Generate privatekey6 - standard - with non-ASCII passphrase - openssl_privatekey: - path: '{{ output_dir }}/privatekey6.pem' - passphrase: ànsïblé - cipher: aes256 + - import_tasks: ../tests/validate.yml -- import_tasks: ../tests/validate.yml + when: cryptography_version.stdout is version('0.5', '>=') + +- name: Check that fingerprints do not depend on the backend + block: + - name: "Fingerprint comparison: pyOpenSSL" + openssl_privatekey: + path: '{{ output_dir }}/fingerprint-{{ item }}.pem' + type: "{{ item }}" + size: 1024 + select_crypto_backend: pyopenssl + loop: + - RSA + - DSA + register: fingerprint_pyopenssl + + - name: "Fingerprint comparison: cryptography" + openssl_privatekey: + path: '{{ output_dir }}/fingerprint-{{ item }}.pem' + type: "{{ item }}" + size: 1024 + select_crypto_backend: cryptography + loop: + - RSA + - DSA + register: fingerprint_cryptography + + - name: Verify that fingerprints match + assert: + that: item.0.fingerprint[item.2] == item.1.fingerprint[item.2] + when: item.0 is not skipped and item.1 is not skipped + loop: | + {{ query('nested', + fingerprint_pyopenssl.results | zip(fingerprint_cryptography.results), + fingerprint_pyopenssl.results[0].fingerprint.keys() + ) if fingerprint_pyopenssl.results[0].fingerprint else [] }} + loop_control: + label: "{{ [item.0.item, item.2] }}" + when: pyopenssl_version.stdout is version('0.6', '>=') and cryptography_version.stdout is version('0.5', '>=') diff --git a/test/integration/targets/openssl_privatekey/tests/validate.yml b/test/integration/targets/openssl_privatekey/tests/validate.yml index f2e39a37f7..145323ee16 100644 --- a/test/integration/targets/openssl_privatekey/tests/validate.yml +++ b/test/integration/targets/openssl_privatekey/tests/validate.yml @@ -1,3 +1,4 @@ +--- - name: Validate privatekey1 (test - RSA key with size 4096 bits) shell: "openssl rsa -noout -text -in {{ output_dir }}/privatekey1.pem | grep Private | sed 's/\\(RSA\\s\\)*Private-Key: (\\(.*\\) bit.*)/\\2/'" register: privatekey1 @@ -18,14 +19,14 @@ - privatekey2.stdout == '2048' -- name: Validate privatekey3 (test - DSA key with size 4096 bits) +- name: Validate privatekey3 (test - DSA key with size 3072 bits) shell: "openssl dsa -noout -text -in {{ output_dir }}/privatekey3.pem | grep Private | sed 's/\\(RSA\\s\\)*Private-Key: (\\(.*\\) bit.*)/\\2/'" register: privatekey3 -- name: Validate privatekey3 (assert - DSA key with size 4096 bits) +- name: Validate privatekey3 (assert - DSA key with size 3072 bits) assert: that: - - privatekey3.stdout == '4096' + - privatekey3.stdout == '3072' - name: Validate privatekey4 (test - Ensure key has been removed) @@ -68,3 +69,38 @@ that: - privatekey6.stdout == '4096' when: openssl_version.stdout is version('0.9.8zh', '>=') + +- name: Validate ECC generation (dump with OpenSSL) + shell: "openssl ec -in {{ output_dir }}/privatekey-{{ item.item.curve }}.pem -noout -text | grep 'ASN1 OID: ' | sed 's/ASN1 OID: \\([^ ]*\\)/\\1/'" + loop: "{{ privatekey_ecc_generate.results }}" + register: privatekey_ecc_dump + when: openssl_version.stdout is version('0.9.8zh', '>=') and 'skip_reason' not in item + loop_control: + label: "{{ item.item.curve }}" + +- name: Validate ECC generation + assert: + that: + - item is changed + loop: "{{ privatekey_ecc_generate.results }}" + when: "'skip_reason' not in item" + loop_control: + label: "{{ item.item.curve }}" + +- name: Validate ECC generation (curve type) + assert: + that: + - "'skip_reason' in item or item.item.item.openssl_name == item.stdout" + loop: "{{ privatekey_ecc_dump.results }}" + when: "'skip_reason' not in item" + loop_control: + label: "{{ item.item.item }} - {{ item.stdout if 'stdout' in item else '' }}" + +- name: Validate ECC generation idempotency + assert: + that: + - item is not changed + loop: "{{ privatekey_ecc_idempotency.results }}" + when: "'skip_reason' not in item" + loop_control: + label: "{{ item.item.curve }}" diff --git a/test/integration/targets/setup_openssl/tasks/main.yml b/test/integration/targets/setup_openssl/tasks/main.yml index 0ccef71cc5..5e93a8c3c1 100644 --- a/test/integration/targets/setup_openssl/tasks/main.yml +++ b/test/integration/targets/setup_openssl/tasks/main.yml @@ -1,3 +1,4 @@ +--- - name: Incluse OS-specific variables include_vars: '{{ ansible_os_family }}.yml' when: not ansible_os_family == "Darwin"