Repository: ansible/ansible

Issue:
Letsencrypt: allow deactivation of authzs after successful certificate issuing
##### ISSUE TYPE
Feature Idea

##### COMPONENT NAME
letsencrypt

##### SUMMARY
Currently, the `letsencrypt` module does not deactivate the `authz` objects it opened on the ACME server. As long as these stay active, a new certificate for these domains (which have valid `authz` objects) can be obtained with only the knowledge of the account key, but without new validations (DNS, file based, ...).

We should offer an option which disables active `authz` objects after the certificate was successfully issued (or also after errors with issuing the certificate), to allow to decrease the attack surface in case the account key is stolen.

See also:
 * https://community.letsencrypt.org/t/upcoming-change-valid-authz-reuse/16982
 * https://community.letsencrypt.org/t/authorization-deactivation/19860/4
 * https://community.letsencrypt.org/t/upcoming-api-changes/17947
 * https://tools.ietf.org/html/draft-ietf-acme-acme-09#section-7.5.2



Discussion:
Files identified in the description:
* [lib/ansible/modules/web_infrastructure/letsencrypt.py](https://github.com/ansible/ansible/blob/devel/lib/ansible/modules/web_infrastructure/letsencrypt.py)

If these files are inaccurate, please update the `component name` section of the description or use the `!component` bot command.

[click here for bot help](https://github.com/ansible/ansibullbot/blob/master/ISSUE_HELP.md)
<!--- boilerplate: components_banner --->
cc @mgruener @resmo
[click here for bot help](https://github.com/ansible/ansibullbot/blob/master/ISSUE_HELP.md)
<!--- boilerplate: notify --->

Resolving patch:
diff --git a/lib/ansible/modules/web_infrastructure/letsencrypt.py b/lib/ansible/modules/web_infrastructure/letsencrypt.py
--- a/lib/ansible/modules/web_infrastructure/letsencrypt.py
+++ b/lib/ansible/modules/web_infrastructure/letsencrypt.py
@@ -159,6 +159,17 @@
     required: false
     default: true
     version_added: 2.5
+  deactivate_authzs:
+    description:
+      - "Deactivate authentication objects (authz) after issuing a certificate,
+         or when issuing the certificate failed."
+      - "Authentication objects are bound to an account key and remain valid
+         for a certain amount of time, and can be used to issue certificates
+         without having to re-authenticate the domain. This can be a security
+         concern. "
+    required: false
+    default: false
+    version_added: 2.6
 '''
 
 EXAMPLES = '''
@@ -335,6 +346,19 @@
 from ansible.module_utils.urls import fetch_url as _fetch_url
 
 
+class ModuleFailException(Exception):
+    '''
+    If raised, module.fail_json() will be called with the given parameters after cleanup.
+    '''
+    def __init__(self, msg, **args):
+        super(ModuleFailException, self).__init__(self, msg)
+        self.msg = msg
+        self.args = args
+
+    def do_fail(self, module):
+        module.fail_json(msg=self.msg, **self.args)
+
+
 def _lowercase_fetch_url(*args, **kwargs):
     '''
      Add lowercase representations of the header names as dict keys
@@ -367,12 +391,12 @@ def simple_get(module, url):
             try:
                 result = module.from_json(content.decode('utf8'))
             except ValueError:
-                module.fail_json(msg="Failed to parse the ACME response: {0} {1}".format(url, content))
+                raise ModuleFailException("Failed to parse the ACME response: {0} {1}".format(url, content))
         else:
             result = content
 
     if info['status'] >= 400:
-        module.fail_json(msg="ACME request failed: CODE: {0} RESULT: {1}".format(info['status'], result))
+        raise ModuleFailException("ACME request failed: CODE: {0} RESULT: {1}".format(info['status'], result))
     return result
 
 
@@ -392,9 +416,9 @@ def get_cert_days(module, cert_file):
         not_after_str = re.search(r"\s+Not After\s*:\s+(.*)", out.decode('utf8')).group(1)
         not_after = datetime.fromtimestamp(time.mktime(time.strptime(not_after_str, '%b %d %H:%M:%S %Y %Z')))
     except AttributeError:
-        module.fail_json(msg="No 'Not after' date found in {0}".format(cert_file))
+        raise ModuleFailException("No 'Not after' date found in {0}".format(cert_file))
     except ValueError:
-        module.fail_json(msg="Failed to parse 'Not after' date of {0}".format(cert_file))
+        raise ModuleFailException("Failed to parse 'Not after' date of {0}".format(cert_file))
     now = datetime.utcnow()
     return (not_after - now).days
 
@@ -414,10 +438,10 @@ def write_file(module, dest, content):
     except Exception as err:
         try:
             f.close()
-        except:
+        except Exception as e:
             pass
         os.remove(tmpsrc)
-        module.fail_json(msg="failed to create temporary content file: %s" % to_native(err), exception=traceback.format_exc())
+        raise ModuleFailException("failed to create temporary content file: %s" % to_native(err), exception=traceback.format_exc())
     f.close()
     checksum_src = None
     checksum_dest = None
@@ -425,34 +449,34 @@ def write_file(module, dest, content):
     if not os.path.exists(tmpsrc):
         try:
             os.remove(tmpsrc)
-        except:
+        except Exception as e:
             pass
-        module.fail_json(msg="Source %s does not exist" % (tmpsrc))
+        raise ModuleFailException("Source %s does not exist" % (tmpsrc))
     if not os.access(tmpsrc, os.R_OK):
         os.remove(tmpsrc)
-        module.fail_json(msg="Source %s not readable" % (tmpsrc))
+        raise ModuleFailException("Source %s not readable" % (tmpsrc))
     checksum_src = module.sha1(tmpsrc)
     # check if there is no dest file
     if os.path.exists(dest):
         # raise an error if copy has no permission on dest
         if not os.access(dest, os.W_OK):
             os.remove(tmpsrc)
-            module.fail_json(msg="Destination %s not writable" % (dest))
+            raise ModuleFailException("Destination %s not writable" % (dest))
         if not os.access(dest, os.R_OK):
             os.remove(tmpsrc)
-            module.fail_json(msg="Destination %s not readable" % (dest))
+            raise ModuleFailException("Destination %s not readable" % (dest))
         checksum_dest = module.sha1(dest)
     else:
         if not os.access(os.path.dirname(dest), os.W_OK):
             os.remove(tmpsrc)
-            module.fail_json(msg="Destination dir %s not writable" % (os.path.dirname(dest)))
+            raise ModuleFailException("Destination dir %s not writable" % (os.path.dirname(dest)))
     if checksum_src != checksum_dest:
         try:
             shutil.copyfile(tmpsrc, dest)
             changed = True
         except Exception as err:
             os.remove(tmpsrc)
-            module.fail_json(msg="failed to copy %s to %s: %s" % (tmpsrc, dest, to_native(err)), exception=traceback.format_exc())
+            raise ModuleFailException("failed to copy %s to %s: %s" % (tmpsrc, dest, to_native(err)), exception=traceback.format_exc())
     os.remove(tmpsrc)
     return changed
 
@@ -477,11 +501,11 @@ def __init__(self, module):
         if self.version == 1:
             for key in ('new-reg', 'new-authz', 'new-cert'):
                 if key not in self.directory:
-                    self.module.fail_json(msg="ACME directory does not seem to follow protocol ACME v1")
+                    raise ModuleFailException("ACME directory does not seem to follow protocol ACME v1")
         if self.version == 2:
             for key in ('newNonce', 'newAccount', 'newOrder'):
                 if key not in self.directory:
-                    self.module.fail_json(msg="ACME directory does not seem to follow protocol ACME v2")
+                    raise ModuleFailException("ACME directory does not seem to follow protocol ACME v2")
 
     def __getitem__(self, key):
         return self.directory[key]
@@ -492,7 +516,7 @@ def get_nonce(self, resource=None):
             url = resource
         dummy, info = fetch_url(self.module, url, method='HEAD')
         if info['status'] not in (200, 204):
-            self.module.fail_json(msg="Failed to get replay-nonce, got status {0}".format(info['status']))
+            raise ModuleFailException("Failed to get replay-nonce, got status {0}".format(info['status']))
         return info['replay-nonce']
 
 
@@ -530,14 +554,14 @@ def __init__(self, module):
             except Exception as err:
                 try:
                     f.close()
-                except:
+                except Exception as e:
                     pass
-                module.fail_json(msg="failed to create temporary content file: %s" % to_native(err), exception=traceback.format_exc())
+                raise ModuleFailException("failed to create temporary content file: %s" % to_native(err), exception=traceback.format_exc())
             f.close()
 
         error, self.key_data = self._parse_account_key(self.key)
         if error:
-            module.fail_json(msg="error while parsing account key: %s" % error)
+            raise ModuleFailException("error while parsing account key: %s" % error)
         self.jwk = self.key_data['jwk']
         self.jws_header = {
             "alg": self.key_data['alg'],
@@ -656,7 +680,7 @@ def send_signed_request(self, url, payload):
                 payload64 = nopad_b64(self.module.jsonify(payload).encode('utf8'))
                 protected64 = nopad_b64(self.module.jsonify(protected).encode('utf8'))
             except Exception as e:
-                self.module.fail_json(msg="Failed to encode payload / headers as JSON: {0}".format(e))
+                raise ModuleFailException("Failed to encode payload / headers as JSON: {0}".format(e))
 
             openssl_sign_cmd = [self._openssl_bin, "dgst", "-{0}".format(self.key_data['hash']), "-sign", self.key]
             sign_payload = "{0}.{1}".format(protected64, payload64).encode('utf8')
@@ -671,8 +695,8 @@ def send_signed_request(self, url, payload):
                     r"prim:\s+INTEGER\s+:([0-9A-F]{1,%s})\n" % expected_len,
                     to_text(der_out, errors='surrogate_or_strict'))
                 if len(sig) != 2:
-                    self.module.fail_json(
-                        msg="failed to generate Elliptic Curve signature; cannot parse DER output: {0}".format(
+                    raise ModuleFailException(
+                        "failed to generate Elliptic Curve signature; cannot parse DER output: {0}".format(
                             to_text(der_out, errors='surrogate_or_strict')))
                 sig[0] = (expected_len - len(sig[0])) * '0' + sig[0]
                 sig[1] = (expected_len - len(sig[1])) * '0' + sig[1]
@@ -706,7 +730,7 @@ def send_signed_request(self, url, payload):
                             failed_tries += 1
                             continue
                     except ValueError:
-                        self.module.fail_json(msg="Failed to parse the ACME response: {0} {1}".format(url, content))
+                        raise ModuleFailException("Failed to parse the ACME response: {0} {1}".format(url, content))
                 else:
                     result = content
 
@@ -757,7 +781,7 @@ def _new_reg(self, contact=None):
             # Account did exist
             return False
         else:
-            self.module.fail_json(msg="Error registering: {0} {1}".format(info['status'], result))
+            raise ModuleFailException("Error registering: {0} {1}".format(info['status'], result))
 
     def init_account(self):
         '''
@@ -819,7 +843,7 @@ def __init__(self, module):
         self.finalize_uri = self.data.get('finalize_uri') if self.data else None
 
         if not os.path.exists(self.csr):
-            module.fail_json(msg="CSR %s not found" % (self.csr))
+            raise ModuleFailException("CSR %s not found" % (self.csr))
 
         self._openssl_bin = module.get_bin_path('openssl', True)
         self.domains = self._get_csr_domains()
@@ -871,7 +895,7 @@ def _new_authz_v1(self, domain):
 
         result, info = self.account.send_signed_request(self.directory['new-authz'], new_authz)
         if info['status'] not in [200, 201]:
-            self.module.fail_json(msg="Error requesting challenges: CODE: {0} RESULT: {1}".format(info['status'], result))
+            raise ModuleFailException("Error requesting challenges: CODE: {0} RESULT: {1}".format(info['status'], result))
         else:
             result['uri'] = info['location']
             return result
@@ -935,7 +959,7 @@ def _fail_challenge(self, domain, auth, error):
                     error_details += ' DETAILS: {0};'.format(challenge['error']['detail'])
                 else:
                     error_details += ';'
-        self.module.fail_json(msg="{0}: {1}".format(error.format(domain), error_details))
+        raise ModuleFailException("{0}: {1}".format(error.format(domain), error_details))
 
     def _validate_challenges(self, domain, auth):
         '''
@@ -956,7 +980,7 @@ def _validate_challenges(self, domain, auth):
             }
             result, info = self.account.send_signed_request(uri, challenge_response)
             if info['status'] not in [200, 202]:
-                self.module.fail_json(msg="Error validating challenge: CODE: {0} RESULT: {1}".format(info['status'], result))
+                raise ModuleFailException("Error validating challenge: CODE: {0} RESULT: {1}".format(info['status'], result))
 
         status = ''
 
@@ -993,7 +1017,7 @@ def _finalize_cert(self):
         }
         result, info = self.account.send_signed_request(self.finalize_uri, new_cert)
         if info['status'] not in [200]:
-            self.module.fail_json(msg="Error new cert: CODE: {0} RESULT: {1}".format(info['status'], result))
+            raise ModuleFailException("Error new cert: CODE: {0} RESULT: {1}".format(info['status'], result))
 
         order = info['location']
 
@@ -1004,7 +1028,7 @@ def _finalize_cert(self):
             status = result['status']
 
         if status != 'valid':
-            self.module.fail_json(msg="Error new cert: CODE: {0} STATUS: {1} RESULT: {2}".format(info['status'], status, result))
+            raise ModuleFailException("Error new cert: CODE: {0} STATUS: {1} RESULT: {2}".format(info['status'], status, result))
 
         return result['certificate']
 
@@ -1028,7 +1052,7 @@ def _download_cert(self, url):
             content = info.get('body')
 
         if not content or not info['content-type'].startswith('application/pem-certificate-chain'):
-            self.module.fail_json(msg="Cannot download certificate chain from {0}: {1} (headers: {2})".format(url, content, info))
+            raise ModuleFailException("Cannot download certificate chain from {0}: {1} (headers: {2})".format(url, content, info))
 
         cert = None
         chain = []
@@ -1057,7 +1081,7 @@ def _download_cert(self, url):
                     chain.append(self._der_to_pem(chain_result.read()))
 
         if cert is None or current:
-            self.module.fail_json(msg="Failed to parse certificate chain download from {0}: {1} (headers: {2})".format(url, content, info))
+            raise ModuleFailException("Failed to parse certificate chain download from {0}: {1} (headers: {2})".format(url, content, info))
         return {'cert': cert, 'chain': chain}
 
     def _new_cert_v1(self):
@@ -1086,7 +1110,7 @@ def _new_cert_v1(self):
                     chain = [self._der_to_pem(chain_result.read())]
 
         if info['status'] not in [200, 201]:
-            self.module.fail_json(msg="Error new cert: CODE: {0} RESULT: {1}".format(info['status'], result))
+            raise ModuleFailException("Error new cert: CODE: {0} RESULT: {1}".format(info['status'], result))
         else:
             return {'cert': self._der_to_pem(result), 'uri': info['location'], 'chain': chain}
 
@@ -1107,7 +1131,7 @@ def _new_order_v2(self):
         result, info = self.account.send_signed_request(self.directory['newOrder'], new_order)
 
         if info['status'] not in [201]:
-            self.module.fail_json(msg="Error new order: CODE: {0} RESULT: {1}".format(info['status'], result))
+            raise ModuleFailException("Error new order: CODE: {0} RESULT: {1}".format(info['status'], result))
 
         for identifier, auth_uri in zip(result['identifiers'], result['authorizations']):
             domain = identifier['value']
@@ -1183,7 +1207,7 @@ def get_certificate(self):
         for domain in self.domains:
             auth = self.authorizations.get(domain)
             if auth is None:
-                self.module.fail_json(msg='Found no authorization information for "{0}"!'.format(domain))
+                raise ModuleFailException('Found no authorization information for "{0}"!'.format(domain))
             if 'status' not in auth:
                 self._fail_challenge(domain, auth, 'Authorization for {0} returned no status')
             if auth['status'] != 'valid':
@@ -1211,6 +1235,32 @@ def get_certificate(self):
             if self.chain_dest and write_file(self.module, self.chain_dest, ("\n".join(chain)).encode('utf8')):
                 self.changed = True
 
+    def deactivate_authzs(self):
+        '''
+        Deactivates all valid authz's. Does not raise exceptions.
+        https://community.letsencrypt.org/t/authorization-deactivation/19860/2
+        https://tools.ietf.org/html/draft-ietf-acme-acme-09#section-7.5.2
+        '''
+        authz_deactivate = {
+            'status': 'deactivated'
+        }
+        if self.version == 1:
+            authz_deactivate['resource'] = 'authz'
+        if self.authorizations:
+            for domain in self.domains:
+                auth = self.authorizations.get(domain)
+                if auth is None or auth.get('status') != 'valid':
+                    continue
+                try:
+                    result, info = self.account.send_signed_request(auth['uri'], authz_deactivate)
+                    if 200 <= info['status'] < 300 and result.get('status') == 'deactivated':
+                        auth['status'] = 'deactivated'
+                except Exception as e:
+                    # Ignore errors on deactivating authzs
+                    pass
+                if auth.get('status') != 'deactivated':
+                    self.module.warn(warning='Could not deactivate authz object {0}.'.format(auth['uri']))
+
 
 def main():
     module = AnsibleModule(
@@ -1230,6 +1280,7 @@ def main():
             chain_dest=dict(required=False, default=None, aliases=['chain'], type='path'),
             remaining_days=dict(required=False, default=10, type='int'),
             validate_certs=dict(required=False, default=True, type='bool'),
+            deactivate_authzs=dict(required=False, default=False, type='bool'),
         ),
         required_one_of=(
             ['account_key_src', 'account_key_content'],
@@ -1250,40 +1301,47 @@ def main():
                             'This should only be done for testing against a local ACME server for ' +
                             'development purposes, but *never* for production purposes.')
 
-    if module.params.get('dest'):
-        cert_days = get_cert_days(module, module.params['dest'])
-    else:
-        cert_days = get_cert_days(module, module.params['fullchain_dest'])
-    if cert_days < module.params['remaining_days']:
-        # If checkmode is active, base the changed state solely on the status
-        # of the certificate file as all other actions (accessing an account, checking
-        # the authorization status...) would lead to potential changes of the current
-        # state
-        if module.check_mode:
-            module.exit_json(changed=True, authorizations={}, challenge_data={}, cert_days=cert_days)
+    try:
+        if module.params.get('dest'):
+            cert_days = get_cert_days(module, module.params['dest'])
         else:
-            client = ACMEClient(module)
-            client.cert_days = cert_days
-            if client.is_first_step():
-                # First run: start challenges / start new order
-                client.start_challenges()
+            cert_days = get_cert_days(module, module.params['fullchain_dest'])
+        if cert_days < module.params['remaining_days']:
+            # If checkmode is active, base the changed state solely on the status
+            # of the certificate file as all other actions (accessing an account, checking
+            # the authorization status...) would lead to potential changes of the current
+            # state
+            if module.check_mode:
+                module.exit_json(changed=True, authorizations={}, challenge_data={}, cert_days=cert_days)
             else:
-                # Second run: finish challenges, and get certificate
-                client.finish_challenges()
-                client.get_certificate()
-            data, data_dns = client.get_challenges_data()
-            module.exit_json(
-                changed=client.changed,
-                authorizations=client.authorizations,
-                finalize_uri=client.finalize_uri,
-                order_uri=client.order_uri,
-                account_uri=client.account.uri,
-                challenge_data=data,
-                challenge_data_dns=data_dns,
-                cert_days=client.cert_days
-            )
-    else:
-        module.exit_json(changed=False, cert_days=cert_days)
+                client = ACMEClient(module)
+                client.cert_days = cert_days
+                if client.is_first_step():
+                    # First run: start challenges / start new order
+                    client.start_challenges()
+                else:
+                    # Second run: finish challenges, and get certificate
+                    try:
+                        client.finish_challenges()
+                        client.get_certificate()
+                    finally:
+                        if module.params['deactivate_authzs']:
+                            client.deactivate_authzs()
+                data, data_dns = client.get_challenges_data()
+                module.exit_json(
+                    changed=client.changed,
+                    authorizations=client.authorizations,
+                    finalize_uri=client.finalize_uri,
+                    order_uri=client.order_uri,
+                    account_uri=client.account.uri,
+                    challenge_data=data,
+                    challenge_data_dns=data_dns,
+                    cert_days=client.cert_days
+                )
+        else:
+            module.exit_json(changed=False, cert_days=cert_days)
+    except ModuleFailException as e:
+        e.do_fail(module)
 
 
 if __name__ == '__main__':


Test patch: