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).
This commit is contained in:
Felix Fontein 2018-01-24 14:38:36 +01:00 committed by ansibot
parent 21169b2228
commit 8095815b32

View file

@ -33,10 +33,10 @@ description:
you to create a SSL certificate with the appropriate subjectAlternativeNames. you to create a SSL certificate with the appropriate subjectAlternativeNames.
It is I(not) the responsibility of this module to perform these steps." 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 - "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 - "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 the Let's Encrypt CA, the module can be used with any service using the ACME
protocol." v1 or v2 protocol."
requirements: requirements:
- "python >= 2.6" - "python >= 2.6"
- openssl - openssl
@ -71,14 +71,32 @@ options:
CA server API." CA server API."
- "For safety reasons the default is set to the Let's Encrypt staging server. - "For safety reasons the default is set to the Let's Encrypt staging server.
This will create technically correct, but untrusted certificates." 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)." trusted certificates, is U(https://acme-v01.api.letsencrypt.org/directory)."
default: https://acme-staging.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: agreement:
description: description:
- "URI to a terms of service document you agree to when using the - "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. - 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: challenge:
description: The challenge to be performed. description: The challenge to be performed.
choices: [ 'http-01', 'dns-01', 'tls-sni-02'] choices: [ 'http-01', 'dns-01', 'tls-sni-02']
@ -220,9 +238,13 @@ authorizations:
type: complex type: complex
contains: contains:
authorization: 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 returned: success
type: dict type: dict
finalization_uri:
description: ACME finalization URI.
returned: changed
type: string
''' '''
import base64 import base64
@ -369,24 +391,35 @@ class ACMEDirectory(object):
and the Replay-Nonce for a given URI. This only works for and the Replay-Nonce for a given URI. This only works for
URIs that permit GET requests (so normally not the ones that URIs that permit GET requests (so normally not the ones that
require authentication). 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): def __init__(self, module):
self.module = module self.module = module
self.directory_root = module.params['acme_directory'] self.directory_root = module.params['acme_directory']
self.version = module.params['acme_version']
self.directory = simple_get(self.module, self.directory_root) 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): def __getitem__(self, key):
return self.directory[key] return self.directory[key]
def get_nonce(self, resource=None): 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: if resource is not None:
url = resource url = resource
_, info = fetch_url(self.module, url, method='HEAD') _, 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'])) self.module.fail_json(msg="Failed to get replay-nonce, got status {0}".format(info['status']))
return info['replay-nonce'] return info['replay-nonce']
@ -400,21 +433,18 @@ class ACMEAccount(object):
def __init__(self, module): def __init__(self, module):
self.module = module self.module = module
self.agreement = module.params['agreement'] self.version = module.params['acme_version']
# account_key path and content are mutually exclusive # account_key path and content are mutually exclusive
self.key = module.params['account_key_src'] self.key = module.params['account_key_src']
self.key_content = module.params['account_key_content'] self.key_content = module.params['account_key_content']
self.email = module.params['account_email'] self.email = module.params['account_email']
self.data = module.params['data']
self.directory = ACMEDirectory(module) 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.uri = None
self.changed = False self.changed = False
self._authz_list_uri = None
self._certs_list_uri = None
self._openssl_bin = module.get_bin_path('openssl', True) self._openssl_bin = module.get_bin_path('openssl', True)
# Create a key file from content, key (path) and key content are mutually exclusive # 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) error, self.key_data = self._parse_account_key(self.key)
if error: if error:
module.fail_json(msg="error while parsing account key: %s" % error) module.fail_json(msg="error while parsing account key: %s" % error)
self.jwk = self.key_data['jwk']
self.jws_header = { self.jws_header = {
"alg": self.key_data['alg'], "alg": self.key_data['alg'],
"jwk": self.key_data['jwk'], "jwk": self.jwk,
} }
self.init_account() self.init_account()
def get_keyauthorization(self, token): def get_keyauthorization(self, token):
''' '''
Returns the key authorization for the given 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()) thumbprint = nopad_b64(hashlib.sha256(accountkey_json.encode('utf8')).digest())
return "{0}.{1}".format(token, thumbprint) 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 Sends a JWS signed HTTP POST request to the ACME server and returns
the response as dictionary 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) failed_tries = 0
protected["nonce"] = self.directory.get_nonce() while True:
protected = copy.deepcopy(self.jws_header)
protected["nonce"] = self.directory.get_nonce()
if self.version != 1:
protected["url"] = url
try: try:
payload64 = nopad_b64(self.module.jsonify(payload).encode('utf8')) payload64 = nopad_b64(self.module.jsonify(payload).encode('utf8'))
protected64 = nopad_b64(self.module.jsonify(protected).encode('utf8')) protected64 = nopad_b64(self.module.jsonify(protected).encode('utf8'))
except Exception as e: except Exception as e:
self.module.fail_json(msg="Failed to encode payload / headers as JSON: {0}".format(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] 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') 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) _, out, _ = self.module.run_command(openssl_sign_cmd, data=sign_payload, check_rc=True, binary_data=True)
if self.key_data['type'] == 'ec': if self.key_data['type'] == 'ec':
_, der_out, _ = self.module.run_command( _, der_out, _ = self.module.run_command(
[self._openssl_bin, "asn1parse", "-inform", "DER"], [self._openssl_bin, "asn1parse", "-inform", "DER"],
data=out, binary_data=True) data=out, binary_data=True)
expected_len = 2 * self.key_data['point_size'] expected_len = 2 * self.key_data['point_size']
sig = re.findall( sig = re.findall(
r"prim:\s+INTEGER\s+:([0-9A-F]{1,%s})\n" % expected_len, r"prim:\s+INTEGER\s+:([0-9A-F]{1,%s})\n" % expected_len,
to_text(der_out, errors='surrogate_or_strict')) to_text(der_out, errors='surrogate_or_strict'))
if len(sig) != 2: if len(sig) != 2:
self.module.fail_json( self.module.fail_json(
msg="failed to generate Elliptic Curve signature; cannot parse DER output: {0}".format( msg="failed to generate Elliptic Curve signature; cannot parse DER output: {0}".format(
to_text(der_out, errors='surrogate_or_strict'))) to_text(der_out, errors='surrogate_or_strict')))
sig[0] = (expected_len - len(sig[0])) * '0' + sig[0] sig[0] = (expected_len - len(sig[0])) * '0' + sig[0]
sig[1] = (expected_len - len(sig[1])) * '0' + sig[1] sig[1] = (expected_len - len(sig[1])) * '0' + sig[1]
out = binascii.unhexlify(sig[0]) + binascii.unhexlify(sig[1]) out = binascii.unhexlify(sig[0]) + binascii.unhexlify(sig[1])
data = self.module.jsonify({ data = {
"header": self.jws_header, "protected": protected64,
"protected": protected64, "payload": payload64,
"payload": payload64, "signature": nopad_b64(to_bytes(out)),
"signature": nopad_b64(to_bytes(out)), }
}) if self.version == 1:
data["header"] = self.jws_header
data = self.module.jsonify(data)
resp, info = fetch_url(self.module, url, data=data, method='POST') resp, info = fetch_url(self.module, url, data=data, method='POST')
result = {} result = {}
try: try:
content = resp.read() content = resp.read()
except AttributeError: except AttributeError:
content = info.get('body') content = info.get('body')
if content: if content:
if info['content-type'].startswith('application/json'): if info['content-type'].startswith('application/json') or 400 <= info['status'] < 600:
try: try:
result = self.module.from_json(content.decode('utf8')) result = self.module.from_json(content.decode('utf8'))
except ValueError: # In case of badNonce error, try again (up to 5 times)
self.module.fail_json(msg="Failed to parse the ACME response: {0} {1}".format(url, content)) # (https://tools.ietf.org/html/draft-ietf-acme-acme-09#section-6.6)
else: if (400 <= info['status'] < 600 and
result = content 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): def _new_reg(self, contact=None):
''' '''
Registers a new ACME account. Returns True if the account was 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 and False if it already existed (e.g. it was not newly
created) 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 contact = [] if contact is None else contact
if self.uri is not None: if self.uri is not None:
return True return True
new_reg = { if self.version == 1:
'resource': 'new-reg', new_reg = {
'agreement': self.agreement, 'resource': 'new-reg',
'contact': contact '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: if 'location' in info:
self.uri = info['location'] 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]: if info['status'] in [200, 201]:
# Account did not exist # Account did not exist
@ -627,7 +686,7 @@ class ACMEAccount(object):
method will always result in an account being present (except method will always result in an account being present (except
on error situations). If the account already exists, it will on error situations). If the account already exists, it will
update the contact information. 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 = [] contact = []
@ -639,12 +698,6 @@ class ACMEAccount(object):
# pre-existing account, get account data... # pre-existing account, get account data...
result, _ = self.send_signed_request(self.uri, {'resource': 'reg'}) 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 # ...and check if update is necessary
do_update = False do_update = False
if 'contact' in result: if 'contact' in result:
@ -659,35 +712,6 @@ class ACMEAccount(object):
result, _ = self.send_signed_request(self.uri, upd_reg) result, _ = self.send_signed_request(self.uri, upd_reg)
self.changed = True 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): class ACMEClient(object):
''' '''
@ -698,14 +722,17 @@ class ACMEClient(object):
def __init__(self, module): def __init__(self, module):
self.module = module self.module = module
self.version = module.params['acme_version']
self.challenge = module.params['challenge'] self.challenge = module.params['challenge']
self.csr = module.params['csr'] self.csr = module.params['csr']
self.dest = module.params['dest'] self.dest = module.params['dest']
self.account = ACMEAccount(module) self.account = ACMEAccount(module)
self.directory = self.account.directory self.directory = self.account.directory
self.authorizations = self.account.get_authorizations() self.data = module.params['data']
self.authorizations = None
self.cert_days = -1 self.cert_days = -1
self.changed = self.account.changed 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): if not os.path.exists(self.csr):
module.fail_json(msg="CSR %s not found" % (self.csr)) module.fail_json(msg="CSR %s not found" % (self.csr))
@ -733,40 +760,18 @@ class ACMEClient(object):
domains.add(san[4:]) domains.add(san[4:])
return domains return domains
def _get_domain_auth(self, domain): def _add_or_update_auth(self, domain, auth):
'''
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):
''' '''
Add or update the given authroization in the global authorizations list. 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 Return True if the auth was updated/added and False if no change was
necessary. necessary.
''' '''
for index, cur_auth in enumerate(self.authorizations): if self.authorizations.get(domain) == auth:
if (cur_auth['uri'] == auth['uri']): return False
# does the auth parameter contain updated data? self.authorizations[domain] = auth
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)
return True return True
def _new_authz(self, domain): def _new_authz_v1(self, domain):
''' '''
Create a new authorization for the given domain. Create a new authorization for the given domain.
Return the authorization object of the new authorization 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 # too complex to be useful and tls-sni-02 is an alternative
# as soon as it is implemented server side # as soon as it is implemented server side
if type == 'http-01': 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 resource = '.well-known/acme-challenge/' + token
value = keyauthorization value = keyauthorization
elif type == 'tls-sni-02': 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() token_digest = hashlib.sha256(token.encode('utf8')).hexdigest()
ka_digest = hashlib.sha256(keyauthorization.encode('utf8')).hexdigest() ka_digest = hashlib.sha256(keyauthorization.encode('utf8')).hexdigest()
len_token_digest = len(token_digest) 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:]), "{0}.{1}.ka.acme.invalid".format(ka_digest[:len_ka_digest // 2], ka_digest[len_ka_digest // 2:]),
] ]
elif type == 'dns-01': 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' resource = '_acme-challenge'
value = nopad_b64(hashlib.sha256(to_bytes(keyauthorization)).digest()) value = nopad_b64(hashlib.sha256(to_bytes(keyauthorization)).digest())
else: else:
@ -830,7 +835,7 @@ class ACMEClient(object):
data[type] = {'resource': resource, 'resource_value': value} data[type] = {'resource': resource, 'resource_value': value}
return data return data
def _validate_challenges(self, auth): def _validate_challenges(self, domain, auth):
''' '''
Validate the authorization provided in the auth dict. Returns True Validate the authorization provided in the auth dict. Returns True
when the validation was successful and False when it was not. when the validation was successful and False when it was not.
@ -839,7 +844,7 @@ class ACMEClient(object):
if self.challenge != challenge['type']: if self.challenge != challenge['type']:
continue continue
uri = challenge['uri'] uri = challenge['uri'] if self.version == 1 else challenge['url']
token = re.sub(r"[^A-Za-z0-9_\-]", "_", challenge['token']) token = re.sub(r"[^A-Za-z0-9_\-]", "_", challenge['token'])
keyauthorization = self.account.get_keyauthorization(token) keyauthorization = self.account.get_keyauthorization(token)
@ -856,12 +861,12 @@ class ACMEClient(object):
while status not in ['valid', 'invalid', 'revoked']: while status not in ['valid', 'invalid', 'revoked']:
result = simple_get(self.module, auth['uri']) result = simple_get(self.module, auth['uri'])
result['uri'] = auth['uri'] result['uri'] = auth['uri']
if self._add_or_update_auth(result): if self._add_or_update_auth(domain, result):
self.changed = True self.changed = True
# draft-ietf-acme-acme-02 # draft-ietf-acme-acme-02
# "status (required, string): ... # "status (required, string): ...
# If this field is missing, then the default value is "pending"." # 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' status = 'pending'
else: else:
status = result['status'] status = result['status']
@ -874,7 +879,9 @@ class ACMEClient(object):
for challenge in result['challenges']: for challenge in result['challenges']:
if challenge['status'] == 'invalid': if challenge['status'] == 'invalid':
error_details += ' CHALLENGE: {0}'.format(challenge['type']) 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']) error_details += ' DETAILS: {0};'.format(challenge['error']['detail'])
else: else:
error_details += ';' error_details += ';'
@ -882,7 +889,88 @@ class ACMEClient(object):
return status == 'valid' 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. Create a new certificate based on the csr.
Return the certificate object as dict Return the certificate object as dict
@ -905,43 +993,64 @@ class ACMEClient(object):
chain_link = parsed_link.group(1) chain_link = parsed_link.group(1)
chain_result, chain_info = fetch_url(self.module, chain_link, method='GET') chain_result, chain_info = fetch_url(self.module, chain_link, method='GET')
if chain_info['status'] in [200, 201]: 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]: if info['status'] not in [200, 201]:
self.module.fail_json(msg="Error new cert: CODE: {0} RESULT: {1}".format(info['status'], result)) self.module.fail_json(msg="Error new cert: CODE: {0} RESULT: {1}".format(info['status'], result))
else: 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): def _new_order_v2(self):
''' identifiers = []
Convert the DER format certificate in der_cert to a PEM format for domain in self.domains:
certificate and return it. identifiers.append({
''' 'type': 'dns',
return """-----BEGIN CERTIFICATE-----\n{0}\n-----END CERTIFICATE-----\n""".format( 'value': domain,
"\n".join(textwrap.wrap(base64.b64encode(der_cert).decode('utf8'), 64))) })
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): def do_challenges(self):
''' '''
Create new authorizations for all domains of the CSR and return Create new authorizations for all domains of the CSR and return
the challenge details for the chosen challenge type. 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 = {} data = {}
for domain in self.domains: for domain, auth in self.authorizations.items():
auth = self._get_domain_auth(domain) # _validate_challenges updates the global authrozation dict,
if auth is None: # so get the current version of the authorization we are working
new_auth = self._new_authz(domain) # on to retrieve the challenge data
self._add_or_update_auth(new_auth) data[domain] = self._get_challenge_data(self.authorizations[domain])
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))
return data return data
@ -953,19 +1062,26 @@ class ACMEClient(object):
''' '''
if self.dest is None: if self.dest is None:
return return
if self.finalize_uri is None and self.version != 1:
return
for domain in self.domains: 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'): if auth is None or ('status' not in auth) or (auth['status'] != 'valid'):
return 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: 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', [])] chain = [link for link in cert.get('chain', [])]
if chain and self.module.params['fullchain']: if chain:
pem_cert += "\n".join(chain) if self.module.params['fullchain']:
pem_cert += "\n".join(chain)
if write_file(self.module, self.dest, pem_cert.encode('utf8')): if write_file(self.module, self.dest, pem_cert.encode('utf8')):
self.cert_days = get_cert_days(self.module, self.dest) self.cert_days = get_cert_days(self.module, self.dest)
@ -976,10 +1092,12 @@ def main():
module = AnsibleModule( module = AnsibleModule(
argument_spec=dict( argument_spec=dict(
account_key_src=dict(type='path', aliases=['account_key']), 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'), 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_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'), 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'), 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'), csr=dict(required=True, aliases=['src'], type='path'),
data=dict(required=False, no_log=True, default=None, type='dict'), data=dict(required=False, no_log=True, default=None, type='dict'),
@ -1013,7 +1131,13 @@ def main():
client.cert_days = cert_days client.cert_days = cert_days
data = client.do_challenges() data = client.do_challenges()
client.get_certificate() 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: else:
module.exit_json(changed=False, cert_days=cert_days) module.exit_json(changed=False, cert_days=cert_days)