Repository: ansible/ansible

Issue:
ACME: allow to revoke certificates
##### ISSUE TYPE
Feature Idea

##### COMPONENT NAME
acme_certificate

##### SUMMARY
The ACME protocol allows to revoke certificates (https://tools.ietf.org/html/draft-ietf-acme-acme-09#section-7.6), but the `letsencrypt` module currently doesn't support this. It would be handy if this could also be automated with ansible.

I think this should go into a new module, though. I'm still opening this as a issue for the `letsencrypt` module so it reaches all current maintainers :)



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 --->
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 --->
-label deprecated
Files identified in the description:
* [lib/ansible/modules/web_infrastructure/acme_certificate.py](https://github.com/ansible/ansible/blob/devel/lib/ansible/modules/web_infrastructure/acme_certificate.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 --->

Resolving patch:
diff --git a/lib/ansible/module_utils/acme.py b/lib/ansible/module_utils/acme.py
--- a/lib/ansible/module_utils/acme.py
+++ b/lib/ansible/module_utils/acme.py
@@ -215,14 +215,15 @@ def __init__(self, module):
                 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:
-            raise ModuleFailException("error while parsing account key: %s" % error)
-        self.jwk = self.key_data['jwk']
-        self.jws_header = {
-            "alg": self.key_data['alg'],
-            "jwk": self.jwk,
-        }
+        if self.key is not None:
+            error, self.key_data = self.parse_account_key(self.key)
+            if error:
+                raise ModuleFailException("error while parsing account key: %s" % error)
+            self.jwk = self.key_data['jwk']
+            self.jws_header = {
+                "alg": self.key_data['alg'],
+                "jwk": self.jwk,
+            }
 
     def get_keyauthorization(self, token):
         '''
@@ -355,22 +356,25 @@ def sign_request(self, protected, payload, key_data, key):
             "signature": nopad_b64(to_bytes(out)),
         }
 
-    def send_signed_request(self, url, payload):
+    def send_signed_request(self, url, payload, key_data=None, key=None, jws_header=None):
         '''
         Sends a JWS signed HTTP POST request to the ACME server and returns
         the response as dictionary
         https://tools.ietf.org/html/draft-ietf-acme-acme-10#section-6.2
         '''
+        key_data = key_data or self.key_data
+        key = key or self.key
+        jws_header = jws_header or self.jws_header
         failed_tries = 0
         while True:
-            protected = copy.deepcopy(self.jws_header)
+            protected = copy.deepcopy(jws_header)
             protected["nonce"] = self.directory.get_nonce()
             if self.version != 1:
                 protected["url"] = url
 
-            data = self.sign_request(protected, payload, self.key_data, self.key)
+            data = self.sign_request(protected, payload, key_data, key)
             if self.version == 1:
-                data["header"] = self.jws_header
+                data["header"] = jws_header
             data = self.module.jsonify(data)
 
             headers = {
diff --git a/lib/ansible/modules/web_infrastructure/acme_certificate_revoke.py b/lib/ansible/modules/web_infrastructure/acme_certificate_revoke.py
new file mode 100644
--- /dev/null
+++ b/lib/ansible/modules/web_infrastructure/acme_certificate_revoke.py
@@ -0,0 +1,208 @@
+#!/usr/bin/python
+# -*- coding: utf-8 -*-
+
+# (c) 2016 Michael Gruener <michael.gruener@chaosmoon.net>
+# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt)
+
+from __future__ import absolute_import, division, print_function
+__metaclass__ = type
+
+
+ANSIBLE_METADATA = {'metadata_version': '1.1',
+                    'status': ['preview'],
+                    'supported_by': 'community'}
+
+
+DOCUMENTATION = '''
+---
+module: acme_certificate_revoke
+author: "Felix Fontein (@felixfontein)"
+version_added: "2.7"
+short_description: Revoke certificates with the ACME protocol.
+description:
+   - "Allows to revoke certificates with the ACME protocol. This protocol
+      is, for example, used by Let's Encrypt."
+   - "Note that exactly one of C(account_key_src), C(account_key_content),
+      C(private_key_src) or C(private_key_content) must be specified."
+   - "Also note that in general, trying to revoke an already revoked
+      certificate will lead to an error. The module tries to detect some
+      common error messages (for example, the ones issued by
+      L(Let's Encrypt,https://letsencrypt.org/)'s
+      L(Boulder,https://github.com/letsencrypt/boulder/) software), but
+      this might stop working and probably will not work for other server
+      softwares."
+extends_documentation_fragment:
+  - acme
+options:
+  certificate:
+    description:
+      - "Path to the certificate to revoke."
+    required: yes
+  private_key_src:
+    description:
+      - "Path to the certificate's private key."
+      - "Note that exactly one of C(account_key_src), C(account_key_content),
+         C(private_key_src) or C(private_key_content) must be specified."
+  private_key_content:
+    description:
+      - "Content of the certificate's private key."
+      - "Note that exactly one of C(account_key_src), C(account_key_content),
+         C(private_key_src) or C(private_key_content) must be specified."
+      - "Warning: the content will be written into a temporary file, which will
+         be deleted by Ansible when the module completes. Since this is an
+         important private key — it can be used to change the account key,
+         or to revoke your certificates without knowing their private keys
+         —, this might not be acceptable."
+  revoke_reason:
+    description:
+      - "One of the revocation reasonCodes defined in
+         L(https://tools.ietf.org/html/rfc5280#section-5.3.1, Section 5.3.1 of RFC5280)."
+      - "Possible values are C(0) (unspecified), C(1) (keyCompromise),
+         C(2) (cACompromise), C(3) (affiliationChanged), C(4) (superseded),
+         C(5) (cessationOfOperation), C(6) (certificateHold),
+         C(8) (removeFromCRL), C(9) (privilegeWithdrawn),
+         C(10) (aACompromise)"
+'''
+
+EXAMPLES = '''
+- name: Revoke certificate with account key
+  acme_certificate_revoke:
+    account_key_src: /etc/pki/cert/private/account.key
+    certificate: /etc/httpd/ssl/sample.com.crt
+
+- name: Revoke certificate with certificate's private key
+  acme_certificate_revoke:
+    private_key_src: /etc/httpd/ssl/sample.com.key
+    certificate: /etc/httpd/ssl/sample.com.crt
+'''
+
+RETURN = '''
+'''
+
+from ansible.module_utils.acme import (
+    ModuleFailException, ACMEAccount, nopad_b64
+)
+
+import base64
+import os
+import tempfile
+import traceback
+
+from ansible.module_utils.basic import AnsibleModule
+from ansible.module_utils._text import to_native
+
+
+def main():
+    module = AnsibleModule(
+        argument_spec=dict(
+            account_key_src=dict(type='path', aliases=['account_key']),
+            account_key_content=dict(type='str', no_log=True),
+            acme_directory=dict(required=False, default='https://acme-staging.api.letsencrypt.org/directory', type='str'),
+            acme_version=dict(required=False, default=1, choices=[1, 2], type='int'),
+            validate_certs=dict(required=False, default=True, type='bool'),
+            private_key_src=dict(type='path'),
+            private_key_content=dict(type='str', no_log=True),
+            certificate=dict(required=True, type='path'),
+            revoke_reason=dict(required=False, type='int'),
+        ),
+        required_one_of=(
+            ['account_key_src', 'account_key_content', 'private_key_src', 'private_key_content'],
+        ),
+        mutually_exclusive=(
+            ['account_key_src', 'account_key_content', 'private_key_src', 'private_key_content'],
+        ),
+        supports_check_mode=False,
+    )
+
+    if not module.params.get('validate_certs'):
+        module.warn(warning='Disabling certificate validation for communications with ACME endpoint. ' +
+                            'This should only be done for testing against a local ACME server for ' +
+                            'development purposes, but *never* for production purposes.')
+
+    try:
+        account = ACMEAccount(module)
+        # Load certificate
+        certificate_lines = []
+        try:
+            with open(module.params.get('certificate'), "rt") as f:
+                header_line_count = 0
+                for line in f:
+                    if line.startswith('-----'):
+                        header_line_count += 1
+                        if header_line_count == 2:
+                            # If certificate file contains other certs appended
+                            # (like intermediate certificates), ignore these.
+                            break
+                        continue
+                    certificate_lines.append(line.strip())
+        except Exception as err:
+            raise ModuleFailException("cannot load certificate file: %s" % to_native(err), exception=traceback.format_exc())
+        certificate = nopad_b64(base64.b64decode(''.join(certificate_lines)))
+        # Construct payload
+        payload = {
+            'certificate': certificate
+        }
+        if module.params.get('revoke_reason') is not None:
+            payload['reason'] = module.params.get('revoke_reason')
+        # Determine endpoint
+        if module.params.get('acme_version') == 1:
+            endpoint = account.directory['revoke-cert']
+            payload['resource'] = 'revoke-cert'
+        else:
+            endpoint = account.directory['revokeCert']
+        # Get hold of private key (if available) and make sure it comes from disk
+        private_key = module.params.get('private_key_src')
+        if module.params.get('private_key_content') is not None:
+            fd, tmpsrc = tempfile.mkstemp()
+            module.add_cleanup_file(tmpsrc)  # Ansible will delete the file on exit
+            f = os.fdopen(fd, 'wb')
+            try:
+                f.write(module.params.get('private_key_content').encode('utf-8'))
+                private_key = tmpsrc
+            except Exception as err:
+                try:
+                    f.close()
+                except Exception as e:
+                    pass
+                raise ModuleFailException("failed to create temporary content file: %s" % to_native(err), exception=traceback.format_exc())
+            f.close()
+        # Revoke certificate
+        if private_key:
+            # Step 1: load and parse private key
+            error, private_key_data = account.parse_account_key(private_key)
+            if error:
+                raise ModuleFailException("error while parsing private key: %s" % error)
+            # Step 2: sign revokation request with private key
+            jws_header = {
+                "alg": private_key_data['alg'],
+                "jwk": private_key_data['jwk'],
+            }
+            result, info = account.send_signed_request(endpoint, payload, key=private_key,
+                                                       key_data=private_key_data, jws_header=jws_header)
+        else:
+            # Step 1: get hold of account URI
+            changed = account.init_account(
+                [],
+                allow_creation=False,
+                update_contact=False,
+            )
+            if changed:
+                raise AssertionError('Unwanted account change')
+            # Step 2: sign revokation request with account key
+            result, info = account.send_signed_request(endpoint, payload)
+        if info['status'] != 200:
+            if module.params.get('acme_version') == 1:
+                error_type = 'urn:acme:error:malformed'
+            else:
+                error_type = 'urn:ietf:params:acme:error:malformed'
+            if result.get('type') == error_type and result.get('detail') == 'Certificate already revoked':
+                # Fallback: boulder returns this in case the certificate was already revoked.
+                module.exit_json(changed=False)
+            raise ModuleFailException('Error revoking certificate: {0} {1}'.format(info['status'], result))
+        module.exit_json(changed=True)
+    except ModuleFailException as e:
+        e.do_fail(module)
+
+
+if __name__ == '__main__':
+    main()


Test patch: