1#!/usr/local/bin/python3.8
2# -*- coding: utf-8 -*-
3
4# Copyright: (c) 2017, Thom Wiggers  <ansible@thomwiggers.nl>
5# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt)
6
7from __future__ import absolute_import, division, print_function
8__metaclass__ = type
9
10
11DOCUMENTATION = r'''
12---
13module: openssl_dhparam
14short_description: Generate OpenSSL Diffie-Hellman Parameters
15description:
16    - This module allows one to (re)generate OpenSSL DH-params.
17    - This module uses file common arguments to specify generated file permissions.
18    - "Please note that the module regenerates existing DH params if they do not
19      match the module's options. If you are concerned that this could overwrite
20      your existing DH params, consider using the I(backup) option."
21    - The module can use the cryptography Python library, or the C(openssl) executable.
22      By default, it tries to detect which one is available. This can be overridden
23      with the I(select_crypto_backend) option.
24requirements:
25    - Either cryptography >= 2.0
26    - Or OpenSSL binary C(openssl)
27author:
28    - Thom Wiggers (@thomwiggers)
29options:
30    state:
31        description:
32            - Whether the parameters should exist or not,
33              taking action if the state is different from what is stated.
34        type: str
35        default: present
36        choices: [ absent, present ]
37    size:
38        description:
39            - Size (in bits) of the generated DH-params.
40        type: int
41        default: 4096
42    force:
43        description:
44            - Should the parameters be regenerated even it it already exists.
45        type: bool
46        default: no
47    path:
48        description:
49            - Name of the file in which the generated parameters will be saved.
50        type: path
51        required: true
52    backup:
53        description:
54            - Create a backup file including a timestamp so you can get the original
55              DH params back if you overwrote them with new ones by accident.
56        type: bool
57        default: no
58    select_crypto_backend:
59        description:
60            - Determines which crypto backend to use.
61            - The default choice is C(auto), which tries to use C(cryptography) if available, and falls back to C(openssl).
62            - If set to C(openssl), will try to use the OpenSSL C(openssl) executable.
63            - If set to C(cryptography), will try to use the L(cryptography,https://cryptography.io/) library.
64        type: str
65        default: auto
66        choices: [ auto, cryptography, openssl ]
67        version_added: "1.0.0"
68    return_content:
69        description:
70            - If set to C(yes), will return the (current or generated) DH params' content as I(dhparams).
71        type: bool
72        default: no
73        version_added: "1.0.0"
74notes:
75- Supports C(check_mode).
76extends_documentation_fragment:
77- files
78seealso:
79- module: community.crypto.x509_certificate
80- module: community.crypto.openssl_csr
81- module: community.crypto.openssl_pkcs12
82- module: community.crypto.openssl_privatekey
83- module: community.crypto.openssl_publickey
84'''
85
86EXAMPLES = r'''
87- name: Generate Diffie-Hellman parameters with the default size (4096 bits)
88  community.crypto.openssl_dhparam:
89    path: /etc/ssl/dhparams.pem
90
91- name: Generate DH Parameters with a different size (2048 bits)
92  community.crypto.openssl_dhparam:
93    path: /etc/ssl/dhparams.pem
94    size: 2048
95
96- name: Force regenerate an DH parameters if they already exist
97  community.crypto.openssl_dhparam:
98    path: /etc/ssl/dhparams.pem
99    force: yes
100'''
101
102RETURN = r'''
103size:
104    description: Size (in bits) of the Diffie-Hellman parameters.
105    returned: changed or success
106    type: int
107    sample: 4096
108filename:
109    description: Path to the generated Diffie-Hellman parameters.
110    returned: changed or success
111    type: str
112    sample: /etc/ssl/dhparams.pem
113backup_file:
114    description: Name of backup file created.
115    returned: changed and if I(backup) is C(yes)
116    type: str
117    sample: /path/to/dhparams.pem.2019-03-09@11:22~
118dhparams:
119    description: The (current or generated) DH params' content.
120    returned: if I(state) is C(present) and I(return_content) is C(yes)
121    type: str
122    version_added: "1.0.0"
123'''
124
125import abc
126import os
127import re
128import tempfile
129import traceback
130
131from distutils.version import LooseVersion
132
133from ansible.module_utils.basic import AnsibleModule, missing_required_lib
134from ansible.module_utils.common.text.converters import to_native
135
136from ansible_collections.community.crypto.plugins.module_utils.io import (
137    load_file_if_exists,
138    write_file,
139)
140
141from ansible_collections.community.crypto.plugins.module_utils.crypto.math import (
142    count_bits,
143)
144
145MINIMAL_CRYPTOGRAPHY_VERSION = '2.0'
146
147CRYPTOGRAPHY_IMP_ERR = None
148try:
149    import cryptography
150    import cryptography.exceptions
151    import cryptography.hazmat.backends
152    import cryptography.hazmat.primitives.asymmetric.dh
153    import cryptography.hazmat.primitives.serialization
154    CRYPTOGRAPHY_VERSION = LooseVersion(cryptography.__version__)
155except ImportError:
156    CRYPTOGRAPHY_IMP_ERR = traceback.format_exc()
157    CRYPTOGRAPHY_FOUND = False
158else:
159    CRYPTOGRAPHY_FOUND = True
160
161
162class DHParameterError(Exception):
163    pass
164
165
166class DHParameterBase(object):
167
168    def __init__(self, module):
169        self.state = module.params['state']
170        self.path = module.params['path']
171        self.size = module.params['size']
172        self.force = module.params['force']
173        self.changed = False
174        self.return_content = module.params['return_content']
175
176        self.backup = module.params['backup']
177        self.backup_file = None
178
179    @abc.abstractmethod
180    def _do_generate(self, module):
181        """Actually generate the DH params."""
182        pass
183
184    def generate(self, module):
185        """Generate DH params."""
186        changed = False
187
188        # ony generate when necessary
189        if self.force or not self._check_params_valid(module):
190            self._do_generate(module)
191            changed = True
192
193        # fix permissions (checking force not necessary as done above)
194        if not self._check_fs_attributes(module):
195            # Fix done implicitly by
196            # AnsibleModule.set_fs_attributes_if_different
197            changed = True
198
199        self.changed = changed
200
201    def remove(self, module):
202        if self.backup:
203            self.backup_file = module.backup_local(self.path)
204        try:
205            os.remove(self.path)
206            self.changed = True
207        except OSError as exc:
208            module.fail_json(msg=to_native(exc))
209
210    def check(self, module):
211        """Ensure the resource is in its desired state."""
212        if self.force:
213            return False
214        return self._check_params_valid(module) and self._check_fs_attributes(module)
215
216    @abc.abstractmethod
217    def _check_params_valid(self, module):
218        """Check if the params are in the correct state"""
219        pass
220
221    def _check_fs_attributes(self, module):
222        """Checks (and changes if not in check mode!) fs attributes"""
223        file_args = module.load_file_common_arguments(module.params)
224        if module.check_file_absent_if_check_mode(file_args['path']):
225            return False
226        return not module.set_fs_attributes_if_different(file_args, False)
227
228    def dump(self):
229        """Serialize the object into a dictionary."""
230
231        result = {
232            'size': self.size,
233            'filename': self.path,
234            'changed': self.changed,
235        }
236        if self.backup_file:
237            result['backup_file'] = self.backup_file
238        if self.return_content:
239            content = load_file_if_exists(self.path, ignore_errors=True)
240            result['dhparams'] = content.decode('utf-8') if content else None
241
242        return result
243
244
245class DHParameterAbsent(DHParameterBase):
246
247    def __init__(self, module):
248        super(DHParameterAbsent, self).__init__(module)
249
250    def _do_generate(self, module):
251        """Actually generate the DH params."""
252        pass
253
254    def _check_params_valid(self, module):
255        """Check if the params are in the correct state"""
256        pass
257
258
259class DHParameterOpenSSL(DHParameterBase):
260
261    def __init__(self, module):
262        super(DHParameterOpenSSL, self).__init__(module)
263        self.openssl_bin = module.get_bin_path('openssl', True)
264
265    def _do_generate(self, module):
266        """Actually generate the DH params."""
267        # create a tempfile
268        fd, tmpsrc = tempfile.mkstemp()
269        os.close(fd)
270        module.add_cleanup_file(tmpsrc)  # Ansible will delete the file on exit
271        # openssl dhparam -out <path> <bits>
272        command = [self.openssl_bin, 'dhparam', '-out', tmpsrc, str(self.size)]
273        rc, dummy, err = module.run_command(command, check_rc=False)
274        if rc != 0:
275            raise DHParameterError(to_native(err))
276        if self.backup:
277            self.backup_file = module.backup_local(self.path)
278        try:
279            module.atomic_move(tmpsrc, self.path)
280        except Exception as e:
281            module.fail_json(msg="Failed to write to file %s: %s" % (self.path, str(e)))
282
283    def _check_params_valid(self, module):
284        """Check if the params are in the correct state"""
285        command = [self.openssl_bin, 'dhparam', '-check', '-text', '-noout', '-in', self.path]
286        rc, out, err = module.run_command(command, check_rc=False)
287        result = to_native(out)
288        if rc != 0:
289            # If the call failed the file probably doesn't exist or is
290            # unreadable
291            return False
292        # output contains "(xxxx bit)"
293        match = re.search(r"Parameters:\s+\((\d+) bit\).*", result)
294        if not match:
295            return False  # No "xxxx bit" in output
296
297        bits = int(match.group(1))
298
299        # if output contains "WARNING" we've got a problem
300        if "WARNING" in result or "WARNING" in to_native(err):
301            return False
302
303        return bits == self.size
304
305
306class DHParameterCryptography(DHParameterBase):
307
308    def __init__(self, module):
309        super(DHParameterCryptography, self).__init__(module)
310        self.crypto_backend = cryptography.hazmat.backends.default_backend()
311
312    def _do_generate(self, module):
313        """Actually generate the DH params."""
314        # Generate parameters
315        params = cryptography.hazmat.primitives.asymmetric.dh.generate_parameters(
316            generator=2,
317            key_size=self.size,
318            backend=self.crypto_backend,
319        )
320        # Serialize parameters
321        result = params.parameter_bytes(
322            encoding=cryptography.hazmat.primitives.serialization.Encoding.PEM,
323            format=cryptography.hazmat.primitives.serialization.ParameterFormat.PKCS3,
324        )
325        # Write result
326        if self.backup:
327            self.backup_file = module.backup_local(self.path)
328        write_file(module, result)
329
330    def _check_params_valid(self, module):
331        """Check if the params are in the correct state"""
332        # Load parameters
333        try:
334            with open(self.path, 'rb') as f:
335                data = f.read()
336            params = self.crypto_backend.load_pem_parameters(data)
337        except Exception as dummy:
338            return False
339        # Check parameters
340        bits = count_bits(params.parameter_numbers().p)
341        return bits == self.size
342
343
344def main():
345    """Main function"""
346
347    module = AnsibleModule(
348        argument_spec=dict(
349            state=dict(type='str', default='present', choices=['absent', 'present']),
350            size=dict(type='int', default=4096),
351            force=dict(type='bool', default=False),
352            path=dict(type='path', required=True),
353            backup=dict(type='bool', default=False),
354            select_crypto_backend=dict(type='str', default='auto', choices=['auto', 'cryptography', 'openssl']),
355            return_content=dict(type='bool', default=False),
356        ),
357        supports_check_mode=True,
358        add_file_common_args=True,
359    )
360
361    base_dir = os.path.dirname(module.params['path']) or '.'
362    if not os.path.isdir(base_dir):
363        module.fail_json(
364            name=base_dir,
365            msg="The directory '%s' does not exist or the file is not a directory" % base_dir
366        )
367
368    if module.params['state'] == 'present':
369        backend = module.params['select_crypto_backend']
370        if backend == 'auto':
371            # Detection what is possible
372            can_use_cryptography = CRYPTOGRAPHY_FOUND and CRYPTOGRAPHY_VERSION >= LooseVersion(MINIMAL_CRYPTOGRAPHY_VERSION)
373            can_use_openssl = module.get_bin_path('openssl', False) is not None
374
375            # First try cryptography, then OpenSSL
376            if can_use_cryptography:
377                backend = 'cryptography'
378            elif can_use_openssl:
379                backend = 'openssl'
380
381            # Success?
382            if backend == 'auto':
383                module.fail_json(msg=("Can't detect either the required Python library cryptography (>= {0}) "
384                                      "or the OpenSSL binary openssl").format(MINIMAL_CRYPTOGRAPHY_VERSION))
385
386        if backend == 'openssl':
387            dhparam = DHParameterOpenSSL(module)
388        elif backend == 'cryptography':
389            if not CRYPTOGRAPHY_FOUND:
390                module.fail_json(msg=missing_required_lib('cryptography >= {0}'.format(MINIMAL_CRYPTOGRAPHY_VERSION)),
391                                 exception=CRYPTOGRAPHY_IMP_ERR)
392            dhparam = DHParameterCryptography(module)
393
394        if module.check_mode:
395            result = dhparam.dump()
396            result['changed'] = module.params['force'] or not dhparam.check(module)
397            module.exit_json(**result)
398
399        try:
400            dhparam.generate(module)
401        except DHParameterError as exc:
402            module.fail_json(msg=to_native(exc))
403    else:
404        dhparam = DHParameterAbsent(module)
405
406        if module.check_mode:
407            result = dhparam.dump()
408            result['changed'] = os.path.exists(module.params['path'])
409            module.exit_json(**result)
410
411        if os.path.exists(module.params['path']):
412            try:
413                dhparam.remove(module)
414            except Exception as exc:
415                module.fail_json(msg=to_native(exc))
416
417    result = dhparam.dump()
418
419    module.exit_json(**result)
420
421
422if __name__ == '__main__':
423    main()
424