Repository: ansible/ansible

Issue:
ACME certificate module should support TLS-ALPN validation method
##### SUMMARY
There's a new validation method for Let's Encrypt coming up: [TLS-ALPN-01](https://tools.ietf.org/html/draft-ietf-acme-tls-alpn-01). See [here](https://community.letsencrypt.org/t/tls-alpn-validation-method/63814/1) for more information.

##### ISSUE TYPE
 - Feature Idea

##### COMPONENT NAME
acme_certificate

##### ANSIBLE VERSION
```
2.7.0
```



Discussion:
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 --->
cc @mgruener @resmo
[click here for bot help](https://github.com/ansible/ansibullbot/blob/master/ISSUE_HELP.md)
<!--- boilerplate: notify --->
Certbot contains a standalone ALPN TLS server (for fulfilling challenges). This should be useful to implement testing this: https://github.com/certbot/certbot/blob/master/acme/acme/standalone.py#L162 [Pebble](https://github.com/letsencrypt/pebble/) seems to have `tls-alpn-01` support as well.

Resolving patch:
diff --git a/lib/ansible/modules/web_infrastructure/acme_certificate.py b/lib/ansible/modules/web_infrastructure/acme_certificate.py
--- a/lib/ansible/modules/web_infrastructure/acme_certificate.py
+++ b/lib/ansible/modules/web_infrastructure/acme_certificate.py
@@ -23,7 +23,8 @@
    - "Create and renew SSL certificates with a CA supporting the
       L(ACME protocol,https://tools.ietf.org/html/draft-ietf-acme-acme-12),
       such as L(Let's Encrypt,https://letsencrypt.org/). The current
-      implementation supports the C(http-01) and C(dns-01) challenges."
+      implementation supports the C(http-01), C(dns-01) and C(tls-alpn-01)
+      challenges."
    - "To use this module, it has to be executed twice. Either as two
       different tasks in the same run or during two runs. Note that the output
       of the first run needs to be recorded and passed to the second run as the
@@ -31,10 +32,12 @@
    - "Between these two tasks you have to fulfill the required steps for the
       chosen challenge by whatever means necessary. For C(http-01) that means
       creating the necessary challenge file on the destination webserver. For
-      C(dns-01) the necessary dns record has to be created.
+      C(dns-01) the necessary dns record has to be created. For C(tls-alpn-01)
+      the necessary certificate has to be created and served.
       It is I(not) the responsibility of this module to perform these steps."
    - "For details on how to fulfill these challenges, you might have to read through
-      L(the specification,https://tools.ietf.org/html/draft-ietf-acme-acme-12#section-8).
+      L(the main ACME specification,https://tools.ietf.org/html/draft-ietf-acme-acme-12#section-8)
+      and the L(TLS-ALPN-01 specification,U(https://tools.ietf.org/html/draft-ietf-acme-tls-alpn-01#section-3).
       Also, consider the examples provided for this module."
    - "Although the defaults are chosen so that the module can be used with
       the Let's Encrypt CA, the module can be used with any service using the ACME
@@ -84,7 +87,7 @@
     version_added: "2.6"
   challenge:
     description: The challenge to be performed.
-    choices: [ 'http-01', 'dns-01']
+    choices: [ 'http-01', 'dns-01', 'tls-alpn-01' ]
     default: 'http-01'
   csr:
     description:
@@ -137,6 +140,8 @@
          If C(cert_days < remaining_days), then it will be renewed.
          If the certificate is not renewed, module return values will not
          include C(challenge_data)."
+      - "To make sure that the certificate is renewed in any case, you can
+         use the C(force) option."
     default: 10
   deactivate_authzs:
     description:
@@ -152,7 +157,7 @@
   force:
     description:
       - Enforces the execution of the challenge and validation, even if an
-        existing certificate is still valid.
+        existing certificate is still valid for more than C(remaining_days).
       - This is especially helpful when having an updated CSR e.g. with
         additional domains for which a new certificate is desired.
     type: bool
@@ -273,7 +278,15 @@
       type: string
       sample: .well-known/acme-challenge/evaGxfADs6pSRb2LAv9IZf17Dt3juxGJ-PCt92wr-oA
     resource_value:
-      description: the value the resource has to produce for the validation
+      description:
+        - The value the resource has to produce for the validation.
+        - For C(http-01) and C(dns-01) challenges, the value can be used as-is.
+        - "For C(tls-alpn-01) challenges, note that this return value contains a
+           Base64 encoded version of the correct binary blob which has to be put
+           into the acmeValidation x509 extension; see
+           U(https://tools.ietf.org/html/draft-ietf-acme-tls-alpn-01#section-3)
+           for details. To do this, you might need the C(b64decode) Jinja filter
+           to extract the binary blob from this return value."
       returned: changed
       type: string
       sample: IlirfxKKXA...17Dt3juxGJ-PCt92wr-oA
@@ -482,6 +495,11 @@ def _get_challenge_data(self, auth, domain):
                 value = nopad_b64(hashlib.sha256(to_bytes(keyauthorization)).digest())
                 record = (resource + domain[1:]) if domain.startswith('*.') else (resource + '.' + domain)
                 data[type] = {'resource': resource, 'resource_value': value, 'record': record}
+            elif type == 'tls-alpn-01':
+                # https://tools.ietf.org/html/draft-ietf-acme-tls-alpn-01#section-3
+                resource = domain
+                value = base64.b64encode(hashlib.sha256(to_bytes(keyauthorization)).digest())
+                data[type] = {'resource': resource, 'resource_value': value}
             else:
                 continue
 
@@ -856,7 +874,7 @@ def main():
             account_email=dict(required=False, default=None, type='str'),
             agreement=dict(required=False, type='str'),
             terms_agreed=dict(required=False, default=False, type='bool'),
-            challenge=dict(required=False, default='http-01', choices=['http-01', 'dns-01'], type='str'),
+            challenge=dict(required=False, default='http-01', choices=['http-01', 'dns-01', 'tls-alpn-01'], type='str'),
             csr=dict(required=True, aliases=['src'], type='path'),
             data=dict(required=False, default=None, type='dict'),
             dest=dict(aliases=['cert'], type='path'),


Test patch:
diff --git a/test/integration/targets/acme_certificate/tasks/main.yml b/test/integration/targets/acme_certificate/tasks/main.yml
--- a/test/integration/targets/acme_certificate/tasks/main.yml
+++ b/test/integration/targets/acme_certificate/tasks/main.yml
@@ -174,6 +174,23 @@
       account_email: ""
   - set_fact:
       cert_5_recreate_3: "{{ challenge_data is changed }}"
+  - name: Obtain cert 6
+    include_tasks: obtain-cert.yml
+    vars:
+      certgen_title: Certificate 6
+      certificate_name: cert-6
+      key_type: rsa
+      rsa_bits: 2048
+      subject_alt_name: "DNS:example.org"
+      subject_alt_name_critical: no
+      account_key: account-ec256
+      challenge: tls-alpn-01
+      modify_account: yes
+      deactivate_authzs: no
+      force: no
+      remaining_days: 10
+      terms_agreed: yes
+      account_email: "example@example.org"
   ## DISSECT CERTIFICATES #######################################################################
   # Make sure certificates are valid. Root certificate for Pebble equals the chain certificate.
   - name: Verifying cert 1
@@ -196,6 +213,10 @@
     command: openssl verify -CAfile "{{ output_dir }}/cert-5-chain.pem" "{{ output_dir }}/cert-5.pem"
     ignore_errors: yes
     register: cert_5_valid
+  - name: Verifying cert 6
+    command: openssl verify -CAfile "{{ output_dir }}/cert-6-chain.pem" "{{ output_dir }}/cert-6.pem"
+    ignore_errors: yes
+    register: cert_6_valid
   # Dump certificate info
   - name: Dumping cert 1
     command: openssl x509 -in "{{ output_dir }}/cert-1.pem" -noout -text
@@ -212,6 +233,9 @@
   - name: Dumping cert 5
     command: openssl x509 -in "{{ output_dir }}/cert-5.pem" -noout -text
     register: cert_5_text
+  - name: Dumping cert 6
+    command: openssl x509 -in "{{ output_dir }}/cert-6.pem" -noout -text
+    register: cert_6_text
 
   - import_tasks: ../tests/validate.yml
 
diff --git a/test/integration/targets/acme_certificate/tests/validate.yml b/test/integration/targets/acme_certificate/tests/validate.yml
--- a/test/integration/targets/acme_certificate/tests/validate.yml
+++ b/test/integration/targets/acme_certificate/tests/validate.yml
@@ -62,3 +62,12 @@
   assert:
     that:
       - cert_5_recreate_3 == True
+
+- name: Check that certificate 6 is valid
+  assert:
+    that:
+      - cert_6_valid is not failed
+- name: Check that certificate 6 contains correct SANs
+  assert:
+    that:
+      - "'DNS:example.org' in cert_6_text.stdout"
diff --git a/test/integration/targets/setup_acme/tasks/obtain-cert.yml b/test/integration/targets/setup_acme/tasks/obtain-cert.yml
--- a/test/integration/targets/setup_acme/tasks/obtain-cert.yml
+++ b/test/integration/targets/setup_acme/tasks/obtain-cert.yml
@@ -88,7 +88,7 @@
     url: "http://{{ acme_host }}:5000/tls-alpn/{{ item.value['tls-alpn-01'].resource }}"
     method: PUT
     body_format: raw
-    body: "{{ item.value['tls-alpn-01'].resource_value | b64encode }}"
+    body: "{{ item.value['tls-alpn-01'].resource_value }}"
     headers:
       content-type: "application/octet-stream"
   with_dict: "{{ challenge_data.challenge_data }}"