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:
parent
21169b2228
commit
8095815b32
1 changed files with 301 additions and 177 deletions
|
@ -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)
|
||||||
|
|
||||||
|
|
Loading…
Reference in a new issue