From 8095815b3299f6acda3b285639346980374c4162 Mon Sep 17 00:00:00 2001 From: Felix Fontein Date: Wed, 24 Jan 2018 14:38:36 +0100 Subject: [PATCH] Changing letsencrypt module to use ACME v2 protocol (#34541) * Adding support for ACME v2 protocol to Let's Encrypt module. * Retry if nonce is invalid. (https://github.com/letsencrypt/Pebble#invalid-anti-replay-nonce-errors) * Add support for errors list (also see letsencrypt/boulder#3339). --- .../modules/web_infrastructure/letsencrypt.py | 486 +++++++++++------- 1 file changed, 305 insertions(+), 181 deletions(-) diff --git a/lib/ansible/modules/web_infrastructure/letsencrypt.py b/lib/ansible/modules/web_infrastructure/letsencrypt.py index cc979ac3271..40cc6dd1c4e 100644 --- a/lib/ansible/modules/web_infrastructure/letsencrypt.py +++ b/lib/ansible/modules/web_infrastructure/letsencrypt.py @@ -33,10 +33,10 @@ description: you to create a SSL certificate with the appropriate subjectAlternativeNames. 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 - U(https://tools.ietf.org/html/draft-ietf-acme-acme-02#section-7)" + U(https://tools.ietf.org/html/draft-ietf-acme-acme-09#section-8)" - "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 - protocol." + v1 or v2 protocol." requirements: - "python >= 2.6" - openssl @@ -71,14 +71,32 @@ options: CA server API." - "For safety reasons the default is set to the Let's Encrypt staging server. This will create technically correct, but untrusted certificates." - - "The production Let's Encrypt ACME directory URL, which produces properly + - "You can find URLs of staging endpoints here: + U(https://letsencrypt.org/docs/staging-environment/)" + - "The production Let's Encrypt ACME v1 directory URL, which produces properly trusted certificates, is U(https://acme-v01.api.letsencrypt.org/directory)." default: https://acme-staging.api.letsencrypt.org/directory + acme_version: + description: + - "The ACME version of the endpoint." + - "Must be 1 for the classic Let's Encrypt ACME endpoint, or 2 for the + new ACME v2 endpoint." + default: 1 + choices: [1, 2] + version_added: "2.5" agreement: description: - "URI to a terms of service document you agree to when using the - ACME service at C(acme_directory)." + ACME v1 service at C(acme_directory)." - Default is latest gathered from C(acme_directory) URL. + - This option will only be used when C(acme_version) is 1. + terms_agreed: + description: + - "Boolean indicating whether you agree to the terms of service document." + - "ACME servers can require this to be true." + - This option will only be used when C(acme_version) is not 1. + default: false + version_added: "2.5" challenge: description: The challenge to be performed. choices: [ 'http-01', 'dns-01', 'tls-sni-02'] @@ -220,9 +238,13 @@ authorizations: type: complex contains: authorization: - description: ACME authorization object. See https://tools.ietf.org/html/draft-ietf-acme-acme-02#section-6.1.2 + description: ACME authorization object. See https://tools.ietf.org/html/draft-ietf-acme-acme-09#section-7.1.4 returned: success type: dict +finalization_uri: + description: ACME finalization URI. + returned: changed + type: string ''' import base64 @@ -369,24 +391,35 @@ class ACMEDirectory(object): and the Replay-Nonce for a given URI. This only works for URIs that permit GET requests (so normally not the ones that require authentication). - https://tools.ietf.org/html/draft-ietf-acme-acme-02#section-6.2 + https://tools.ietf.org/html/draft-ietf-acme-acme-09#section-7.1.1 ''' def __init__(self, module): self.module = module self.directory_root = module.params['acme_directory'] + self.version = module.params['acme_version'] self.directory = simple_get(self.module, self.directory_root) + # Check whether self.version matches what we expect + 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") + 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") + def __getitem__(self, key): return self.directory[key] def get_nonce(self, resource=None): - url = self.directory_root + url = self.directory_root if self.version == 1 else self.directory['newNonce'] if resource is not None: url = resource _, info = fetch_url(self.module, url, method='HEAD') - if info['status'] != 200: + if info['status'] not in (200, 204): self.module.fail_json(msg="Failed to get replay-nonce, got status {0}".format(info['status'])) return info['replay-nonce'] @@ -400,21 +433,18 @@ class ACMEAccount(object): def __init__(self, module): self.module = module - self.agreement = module.params['agreement'] + self.version = module.params['acme_version'] # account_key path and content are mutually exclusive self.key = module.params['account_key_src'] self.key_content = module.params['account_key_content'] self.email = module.params['account_email'] - self.data = module.params['data'] self.directory = ACMEDirectory(module) - self.agreement = module.params['agreement'] or self.directory['meta']['terms-of-service'] + self.agreement = module.params.get('agreement') + self.terms_agreed = module.params.get('terms_agreed') self.uri = None self.changed = False - self._authz_list_uri = None - self._certs_list_uri = None - self._openssl_bin = module.get_bin_path('openssl', True) # Create a key file from content, key (path) and key content are mutually exclusive @@ -433,18 +463,19 @@ class ACMEAccount(object): error, self.key_data = self._parse_account_key(self.key) if error: module.fail_json(msg="error while parsing account key: %s" % error) + self.jwk = self.key_data['jwk'] self.jws_header = { "alg": self.key_data['alg'], - "jwk": self.key_data['jwk'], + "jwk": self.jwk, } self.init_account() def get_keyauthorization(self, token): ''' Returns the key authorization for the given token - https://tools.ietf.org/html/draft-ietf-acme-acme-02#section-7.1 + https://tools.ietf.org/html/draft-ietf-acme-acme-09#section-8.1 ''' - accountkey_json = json.dumps(self.jws_header['jwk'], sort_keys=True, separators=(',', ':')) + accountkey_json = json.dumps(self.jwk, sort_keys=True, separators=(',', ':')) thumbprint = nopad_b64(hashlib.sha256(accountkey_json.encode('utf8')).digest()) return "{0}.{1}".format(token, thumbprint) @@ -531,83 +562,111 @@ class ACMEAccount(object): ''' 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-02#section-5.2 + https://tools.ietf.org/html/draft-ietf-acme-acme-09#section-6.2 ''' - protected = copy.deepcopy(self.jws_header) - protected["nonce"] = self.directory.get_nonce() + failed_tries = 0 + while True: + protected = copy.deepcopy(self.jws_header) + protected["nonce"] = self.directory.get_nonce() + if self.version != 1: + protected["url"] = url - try: - 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)) - - 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') - _, out, _ = self.module.run_command(openssl_sign_cmd, data=sign_payload, check_rc=True, binary_data=True) - - if self.key_data['type'] == 'ec': - _, der_out, _ = self.module.run_command( - [self._openssl_bin, "asn1parse", "-inform", "DER"], - data=out, binary_data=True) - expected_len = 2 * self.key_data['point_size'] - sig = re.findall( - 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( - 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] - out = binascii.unhexlify(sig[0]) + binascii.unhexlify(sig[1]) - - data = self.module.jsonify({ - "header": self.jws_header, - "protected": protected64, - "payload": payload64, - "signature": nopad_b64(to_bytes(out)), - }) - - resp, info = fetch_url(self.module, url, data=data, method='POST') - result = {} - try: - content = resp.read() - except AttributeError: - content = info.get('body') + try: + 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)) + + 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') + _, out, _ = self.module.run_command(openssl_sign_cmd, data=sign_payload, check_rc=True, binary_data=True) + + if self.key_data['type'] == 'ec': + _, der_out, _ = self.module.run_command( + [self._openssl_bin, "asn1parse", "-inform", "DER"], + data=out, binary_data=True) + expected_len = 2 * self.key_data['point_size'] + sig = re.findall( + 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( + 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] + out = binascii.unhexlify(sig[0]) + binascii.unhexlify(sig[1]) + + data = { + "protected": protected64, + "payload": payload64, + "signature": nopad_b64(to_bytes(out)), + } + if self.version == 1: + data["header"] = self.jws_header + data = self.module.jsonify(data) - if content: - if info['content-type'].startswith('application/json'): - try: - result = self.module.from_json(content.decode('utf8')) - except ValueError: - self.module.fail_json(msg="Failed to parse the ACME response: {0} {1}".format(url, content)) - else: - result = content + resp, info = fetch_url(self.module, url, data=data, method='POST') + result = {} + try: + content = resp.read() + except AttributeError: + content = info.get('body') + + if content: + if info['content-type'].startswith('application/json') or 400 <= info['status'] < 600: + try: + result = self.module.from_json(content.decode('utf8')) + # In case of badNonce error, try again (up to 5 times) + # (https://tools.ietf.org/html/draft-ietf-acme-acme-09#section-6.6) + if (400 <= info['status'] < 600 and + result.get('type') == 'urn:ietf:params:acme:error:badNonce' and + failed_tries <= 5): + failed_tries += 1 + continue + except ValueError: + self.module.fail_json(msg="Failed to parse the ACME response: {0} {1}".format(url, content)) + else: + result = content - return result, info + return result, info def _new_reg(self, contact=None): ''' Registers a new ACME account. Returns True if the account was created and False if it already existed (e.g. it was not newly created) - https://tools.ietf.org/html/draft-ietf-acme-acme-02#section-6.3 + https://tools.ietf.org/html/draft-ietf-acme-acme-09#section-7.3 ''' contact = [] if contact is None else contact if self.uri is not None: return True - new_reg = { - 'resource': 'new-reg', - 'agreement': self.agreement, - 'contact': contact - } + if self.version == 1: + new_reg = { + 'resource': 'new-reg', + 'contact': contact + } + if self.agreement: + new_reg['agreement'] = self.agreement + else: + new_reg['agreement'] = self.directory['meta']['terms-of-service'] + url = self.directory['new-reg'] + else: + new_reg = { + 'contact': contact + } + if self.terms_agreed: + new_reg['termsOfServiceAgreed'] = True + url = self.directory['newAccount'] - result, info = self.send_signed_request(self.directory['new-reg'], new_reg) + result, info = self.send_signed_request(url, new_reg) if 'location' in info: self.uri = info['location'] + if self.version != 1: + self.jws_header.pop('jwk') + self.jws_header['kid'] = self.uri if info['status'] in [200, 201]: # Account did not exist @@ -627,7 +686,7 @@ class ACMEAccount(object): method will always result in an account being present (except on error situations). If the account already exists, it will update the contact information. - https://tools.ietf.org/html/draft-ietf-acme-acme-02#section-6.3 + https://tools.ietf.org/html/draft-ietf-acme-acme-09#section-7.3 ''' contact = [] @@ -639,12 +698,6 @@ class ACMEAccount(object): # pre-existing account, get account data... result, _ = self.send_signed_request(self.uri, {'resource': 'reg'}) - # XXX: letsencrypt/boulder#1435 - if 'authorizations' in result: - self._authz_list_uri = result['authorizations'] - if 'certificates' in result: - self._certs_list_uri = result['certificates'] - # ...and check if update is necessary do_update = False if 'contact' in result: @@ -659,35 +712,6 @@ class ACMEAccount(object): result, _ = self.send_signed_request(self.uri, upd_reg) self.changed = True - def get_authorizations(self): - ''' - Return a list of currently active authorizations - https://tools.ietf.org/html/draft-ietf-acme-acme-02#section-6.4 - ''' - authz_list = {'authorizations': []} - if self._authz_list_uri is None: - # XXX: letsencrypt/boulder#1435 - # Workaround, retrieve the known authorization urls - # from the data attribute - # It is also a way to limit the queried authorizations, which - # might become relevant at some point - if (self.data is not None) and ('authorizations' in self.data): - for auth in self.data['authorizations']: - authz_list['authorizations'].append(auth['uri']) - else: - return [] - else: - # TODO: need to handle pagination - authz_list = simple_get(self.module, self._authz_list_uri) - - authz = [] - for auth_uri in authz_list['authorizations']: - auth = simple_get(self.module, auth_uri) - auth['uri'] = auth_uri - authz.append(auth) - - return authz - class ACMEClient(object): ''' @@ -698,14 +722,17 @@ class ACMEClient(object): def __init__(self, module): self.module = module + self.version = module.params['acme_version'] self.challenge = module.params['challenge'] self.csr = module.params['csr'] self.dest = module.params['dest'] self.account = ACMEAccount(module) self.directory = self.account.directory - self.authorizations = self.account.get_authorizations() + self.data = module.params['data'] + self.authorizations = None self.cert_days = -1 self.changed = self.account.changed + 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)) @@ -733,40 +760,18 @@ class ACMEClient(object): domains.add(san[4:]) return domains - def _get_domain_auth(self, domain): - ''' - Get the status string of the first authorization for the given domain. - Return None if no active authorization for the given domain was found. - ''' - if self.authorizations is None: - return None - - for auth in self.authorizations: - if (auth['identifier']['type'] == 'dns') and (auth['identifier']['value'] == domain): - return auth - return None - - def _add_or_update_auth(self, auth): + def _add_or_update_auth(self, domain, auth): ''' Add or update the given authroization in the global authorizations list. Return True if the auth was updated/added and False if no change was necessary. ''' - for index, cur_auth in enumerate(self.authorizations): - if (cur_auth['uri'] == auth['uri']): - # does the auth parameter contain updated data? - if cur_auth != auth: - # yes, update our current authorization list - self.authorizations[index] = auth - return True - else: - return False - # this is a new authorization, add it to the list of current - # authorizations - self.authorizations.append(auth) + if self.authorizations.get(domain) == auth: + return False + self.authorizations[domain] = auth return True - def _new_authz(self, domain): + def _new_authz_v1(self, domain): ''' Create a new authorization for the given domain. Return the authorization object of the new authorization @@ -806,11 +811,11 @@ class ACMEClient(object): # too complex to be useful and tls-sni-02 is an alternative # as soon as it is implemented server side if type == 'http-01': - # https://tools.ietf.org/html/draft-ietf-acme-acme-02#section-7.2 + # https://tools.ietf.org/html/draft-ietf-acme-acme-09#section-8.3 resource = '.well-known/acme-challenge/' + token value = keyauthorization elif type == 'tls-sni-02': - # https://tools.ietf.org/html/draft-ietf-acme-acme-02#section-7.3 + # https://tools.ietf.org/html/draft-ietf-acme-acme-09#section-8.4 token_digest = hashlib.sha256(token.encode('utf8')).hexdigest() ka_digest = hashlib.sha256(keyauthorization.encode('utf8')).hexdigest() len_token_digest = len(token_digest) @@ -821,7 +826,7 @@ class ACMEClient(object): "{0}.{1}.ka.acme.invalid".format(ka_digest[:len_ka_digest // 2], ka_digest[len_ka_digest // 2:]), ] elif type == 'dns-01': - # https://tools.ietf.org/html/draft-ietf-acme-acme-02#section-7.4 + # https://tools.ietf.org/html/draft-ietf-acme-acme-09#section-8.5 resource = '_acme-challenge' value = nopad_b64(hashlib.sha256(to_bytes(keyauthorization)).digest()) else: @@ -830,7 +835,7 @@ class ACMEClient(object): data[type] = {'resource': resource, 'resource_value': value} return data - def _validate_challenges(self, auth): + def _validate_challenges(self, domain, auth): ''' Validate the authorization provided in the auth dict. Returns True when the validation was successful and False when it was not. @@ -839,7 +844,7 @@ class ACMEClient(object): if self.challenge != challenge['type']: continue - uri = challenge['uri'] + uri = challenge['uri'] if self.version == 1 else challenge['url'] token = re.sub(r"[^A-Za-z0-9_\-]", "_", challenge['token']) keyauthorization = self.account.get_keyauthorization(token) @@ -856,12 +861,12 @@ class ACMEClient(object): while status not in ['valid', 'invalid', 'revoked']: result = simple_get(self.module, auth['uri']) result['uri'] = auth['uri'] - if self._add_or_update_auth(result): + if self._add_or_update_auth(domain, result): self.changed = True # draft-ietf-acme-acme-02 # "status (required, string): ... # If this field is missing, then the default value is "pending"." - if 'status' not in result: + if self.version == 1 and 'status' not in result: status = 'pending' else: status = result['status'] @@ -874,7 +879,9 @@ class ACMEClient(object): for challenge in result['challenges']: if challenge['status'] == 'invalid': error_details += ' CHALLENGE: {0}'.format(challenge['type']) - if 'error' in challenge: + if 'errors' in challenge: + error_details += ' DETAILS: {0};'.format('; '.join([error['detail'] for error in challenge['errors']])) + elif 'error' in challenge: error_details += ' DETAILS: {0};'.format(challenge['error']['detail']) else: error_details += ';' @@ -882,7 +889,88 @@ class ACMEClient(object): return status == 'valid' - def _new_cert(self): + def _finalize_cert(self): + ''' + Create a new certificate based on the csr. + Return the certificate object as dict + https://tools.ietf.org/html/draft-ietf-acme-acme-09#section-7.4 + ''' + openssl_csr_cmd = [self._openssl_bin, "req", "-in", self.csr, "-outform", "DER"] + _, out, _ = self.module.run_command(openssl_csr_cmd, check_rc=True) + + new_cert = { + "csr": nopad_b64(to_bytes(out)), + } + 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)) + + order = info['location'] + + status = result['status'] + while status not in ['valid', 'invalid']: + time.sleep(2) + result = simple_get(self.module, order) + 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)) + + return result['certificate'] + + def _der_to_pem(self, der_cert): + ''' + Convert the DER format certificate in der_cert to a PEM format + certificate and return it. + ''' + return """-----BEGIN CERTIFICATE-----\n{0}\n-----END CERTIFICATE-----\n""".format( + "\n".join(textwrap.wrap(base64.b64encode(der_cert).decode('utf8'), 64))) + + def _download_cert(self, url): + ''' + Download and parse the certificate chain. + https://tools.ietf.org/html/draft-ietf-acme-acme-09#section-7.4.2 + ''' + resp, info = fetch_url(self.module, url, headers={'Accept': 'application/pem-certificate-chain'}) + try: + content = resp.read() + except AttributeError: + 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)) + + cert = None + chain = [] + + # Parse data + lines = content.decode('utf-8').splitlines(True) + current = [] + for line in lines: + if line.strip(): + current.append(line) + if line.startswith('-----END CERTIFICATE-----'): + if cert is None: + cert = ''.join(current) + else: + chain.append(''.join(current)) + current = [] + + # Process link-up headers if there was no chain in reply + if not chain and 'link' in info: + link = info['link'] + parsed_link = re.match(r'<(.+)>;rel="(\w+)"', link) + if parsed_link and parsed_link.group(2) == "up": + chain_link = parsed_link.group(1) + chain_result, chain_info = fetch_url(self.module, chain_link, method='GET') + if chain_info['status'] in [200, 201]: + 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)) + return {'cert': cert, 'chain': chain} + + def _new_cert_v1(self): ''' Create a new certificate based on the csr. Return the certificate object as dict @@ -905,43 +993,64 @@ class ACMEClient(object): chain_link = parsed_link.group(1) chain_result, chain_info = fetch_url(self.module, chain_link, method='GET') if chain_info['status'] in [200, 201]: - chain = [chain_result.read()] + 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)) else: - return {'cert': result, 'uri': info['location'], 'chain': chain} + return {'cert': self._der_to_pem(result), 'uri': info['location'], 'chain': chain} - def _der_to_pem(self, der_cert): - ''' - Convert the DER format certificate in der_cert to a PEM format - certificate and return it. - ''' - return """-----BEGIN CERTIFICATE-----\n{0}\n-----END CERTIFICATE-----\n""".format( - "\n".join(textwrap.wrap(base64.b64encode(der_cert).decode('utf8'), 64))) + def _new_order_v2(self): + identifiers = [] + for domain in self.domains: + identifiers.append({ + 'type': 'dns', + 'value': domain, + }) + new_order = { + "identifiers": identifiers + } + 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)) + + for identifier, auth_uri in zip(result['identifiers'], result['authorizations']): + domain = identifier['value'] + auth_data = simple_get(self.module, auth_uri) + auth_data['uri'] = auth_uri + self.authorizations[domain] = auth_data + + self.finalize_uri = result['finalize'] def do_challenges(self): ''' Create new authorizations for all domains of the CSR and return the challenge details for the chosen challenge type. ''' + self.authorizations = {} + if (self.data is None) or ('authorizations' not in self.data): + # First run: start new order + if self.version == 1: + for domain in self.domains: + new_auth = self._new_authz_v1(domain) + self._add_or_update_auth(domain, new_auth) + else: + self._new_order_v2() + self.changed = True + else: + # Second run: verify challenges + for domain, auth in self.data['authorizations'].items(): + self.authorizations[domain] = auth + if auth['status'] == 'pending': + self._validate_challenges(domain, auth) + data = {} - for domain in self.domains: - auth = self._get_domain_auth(domain) - if auth is None: - new_auth = self._new_authz(domain) - self._add_or_update_auth(new_auth) - data[domain] = self._get_challenge_data(new_auth) - self.changed = True - elif (auth['status'] == 'pending') or ('status' not in auth): - # draft-ietf-acme-acme-02 - # "status (required, string): ... - # If this field is missing, then the default value is "pending"." - self._validate_challenges(auth) - # _validate_challenges updates the global authrozation dict, - # so get the current version of the authorization we are working - # on to retrieve the challenge data - data[domain] = self._get_challenge_data(self._get_domain_auth(domain)) + for domain, auth in self.authorizations.items(): + # _validate_challenges updates the global authrozation dict, + # so get the current version of the authorization we are working + # on to retrieve the challenge data + data[domain] = self._get_challenge_data(self.authorizations[domain]) return data @@ -953,19 +1062,26 @@ class ACMEClient(object): ''' if self.dest is None: return + if self.finalize_uri is None and self.version != 1: + return for domain in self.domains: - auth = self._get_domain_auth(domain) + auth = self.authorizations.get(domain) if auth is None or ('status' not in auth) or (auth['status'] != 'valid'): return - cert = self._new_cert() + if self.version == 1: + cert = self._new_cert_v1() + else: + cert_uri = self._finalize_cert() + cert = self._download_cert(cert_uri) if cert['cert'] is not None: - pem_cert = self._der_to_pem(cert['cert']) + pem_cert = cert['cert'] - chain = [self._der_to_pem(link) for link in cert.get('chain', [])] - if chain and self.module.params['fullchain']: - pem_cert += "\n".join(chain) + chain = [link for link in cert.get('chain', [])] + if chain: + if self.module.params['fullchain']: + pem_cert += "\n".join(chain) if write_file(self.module, self.dest, pem_cert.encode('utf8')): self.cert_days = get_cert_days(self.module, self.dest) @@ -976,10 +1092,12 @@ def main(): module = AnsibleModule( argument_spec=dict( account_key_src=dict(type='path', aliases=['account_key']), - account_key_content=dict(type='str'), + account_key_content=dict(type='str', no_log=True), account_email=dict(required=False, default=None, type='str'), acme_directory=dict(required=False, default='https://acme-staging.api.letsencrypt.org/directory', type='str'), + acme_version=dict(required=False, default=1, type='int'), 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', 'tls-sni-02'], type='str'), csr=dict(required=True, aliases=['src'], type='path'), data=dict(required=False, no_log=True, default=None, type='dict'), @@ -1013,7 +1131,13 @@ def main(): client.cert_days = cert_days data = client.do_challenges() client.get_certificate() - module.exit_json(changed=client.changed, authorizations=client.authorizations, challenge_data=data, cert_days=client.cert_days) + module.exit_json( + changed=client.changed, + authorizations=client.authorizations, + finalize_uri=client.finalize_uri, + challenge_data=data, + cert_days=client.cert_days + ) else: module.exit_json(changed=False, cert_days=cert_days)