2019-03-18 20:58:30 +01:00
|
|
|
#!/usr/bin/env python
|
|
|
|
# -*- coding: utf-8 -*-
|
|
|
|
|
2019-04-04 15:12:55 +02:00
|
|
|
# acertmgr - acme api v2 functions (implements RFC8555)
|
2019-03-18 20:58:30 +01:00
|
|
|
# Copyright (c) Rudolf Mayerhofer, 2019.
|
|
|
|
# available under the ISC license, see LICENSE
|
|
|
|
|
|
|
|
import copy
|
|
|
|
import json
|
|
|
|
import re
|
|
|
|
import time
|
|
|
|
|
|
|
|
from acertmgr import tools
|
|
|
|
from acertmgr.authority.acme import ACMEAuthority as AbstractACMEAuthority
|
2019-04-04 13:15:34 +02:00
|
|
|
from acertmgr.tools import log
|
2019-03-18 20:58:30 +01:00
|
|
|
|
|
|
|
|
|
|
|
class ACMEAuthority(AbstractACMEAuthority):
|
|
|
|
# @brief Init class with config
|
|
|
|
# @param config Configuration data
|
|
|
|
# @param key Account key data
|
|
|
|
def __init__(self, config, key):
|
|
|
|
AbstractACMEAuthority.__init__(self, config, key)
|
|
|
|
# Initialize config vars
|
|
|
|
self.ca = config['authority']
|
2019-03-20 10:34:59 +01:00
|
|
|
self.tos_agreed = str(config.get('authority_tos_agreement')).lower() == 'true'
|
2019-03-18 20:58:30 +01:00
|
|
|
contact_email = config.get('authority_contact_email')
|
|
|
|
if contact_email is None:
|
|
|
|
self.contact = None
|
|
|
|
elif isinstance(contact_email, list):
|
|
|
|
self.contact = ["mailto:{}".format(contact) for contact in contact_email]
|
|
|
|
else:
|
|
|
|
self.contact = ["mailto:{}".format(contact_email)]
|
|
|
|
|
|
|
|
# Initialize runtime vars
|
2019-03-22 12:12:16 +01:00
|
|
|
code, self.directory, _ = self._request_url(self.ca + '/directory')
|
|
|
|
if code >= 400 or not self.directory:
|
|
|
|
self.directory = {
|
|
|
|
"meta": {},
|
|
|
|
"newAccount": "{}/acme/new-acct".format(self.ca),
|
|
|
|
"newNonce": "{}/acme/new-nonce".format(self.ca),
|
|
|
|
"newOrder": "{}/acme/new-order".format(self.ca),
|
2019-03-27 21:00:21 +01:00
|
|
|
"revokeCert": "{}/acme/revoke-cert".format(self.ca),
|
2019-03-22 12:12:16 +01:00
|
|
|
}
|
2019-04-04 13:15:34 +02:00
|
|
|
log("API directory retrieval failed ({}). Guessed necessary values: {}".format(code, self.directory),
|
|
|
|
warning=True)
|
2019-03-27 12:49:41 +01:00
|
|
|
self.nonce = None
|
2019-03-18 20:58:30 +01:00
|
|
|
|
2019-04-04 10:30:44 +02:00
|
|
|
self.algorithm, jwk = tools.get_key_alg_and_jwk(key)
|
2019-03-18 20:58:30 +01:00
|
|
|
self.account_protected = {
|
|
|
|
"alg": self.algorithm,
|
2019-04-04 10:30:44 +02:00
|
|
|
"jwk": jwk
|
2019-03-18 20:58:30 +01:00
|
|
|
}
|
|
|
|
self.account_id = None # will be updated to correct value during account registration
|
|
|
|
|
|
|
|
# @brief fetch a given url
|
|
|
|
def _request_url(self, url, data=None, raw_result=False):
|
|
|
|
header = {'Content-Type': 'application/jose+json'}
|
|
|
|
if data:
|
2019-04-02 10:15:55 +02:00
|
|
|
# Always encode data to bytes
|
2019-03-18 20:58:30 +01:00
|
|
|
data = data.encode('utf-8')
|
2019-04-02 10:15:55 +02:00
|
|
|
try:
|
|
|
|
resp = tools.get_url(url, data, header)
|
|
|
|
except IOError as e:
|
|
|
|
body = getattr(e, "read", e.__str__)()
|
|
|
|
if getattr(body, 'decode', None):
|
|
|
|
# Decode function available? Use it to get a proper str
|
|
|
|
body = body.decode('utf-8')
|
|
|
|
return getattr(e, "code", 999), body, {}
|
2019-03-22 15:54:48 +01:00
|
|
|
|
|
|
|
# Store next Replay-Nonce if it is in the header
|
|
|
|
if 'Replay-Nonce' in resp.headers:
|
|
|
|
self.nonce = resp.headers['Replay-Nonce']
|
|
|
|
|
2019-03-18 20:58:30 +01:00
|
|
|
body = resp.read()
|
2019-04-02 10:15:55 +02:00
|
|
|
if getattr(body, 'decode', None):
|
|
|
|
# Decode function available? Use it to get a proper str
|
|
|
|
body = body.decode('utf-8')
|
2019-03-27 12:49:41 +01:00
|
|
|
if not raw_result and len(body) > 0:
|
2019-03-18 20:58:30 +01:00
|
|
|
try:
|
2019-04-02 10:15:55 +02:00
|
|
|
body = json.loads(body)
|
2019-03-18 20:58:30 +01:00
|
|
|
except json.JSONDecodeError as e:
|
|
|
|
raise ValueError('Could not parse non-raw result (expected JSON)', e)
|
|
|
|
|
|
|
|
return resp.getcode(), body, resp.headers
|
|
|
|
|
2019-04-04 15:12:55 +02:00
|
|
|
# @brief fetch an url with a signed request
|
2019-03-18 20:58:30 +01:00
|
|
|
def _request_acme_url(self, url, payload=None, protected=None, raw_result=False):
|
|
|
|
if not protected:
|
|
|
|
protected = {}
|
2019-04-04 15:12:55 +02:00
|
|
|
|
|
|
|
if payload:
|
|
|
|
payload64 = tools.bytes_to_base64url(json.dumps(payload).encode('utf8'))
|
|
|
|
else:
|
|
|
|
payload64 = "" # for POST-as-GET
|
2019-03-18 20:58:30 +01:00
|
|
|
|
|
|
|
# Request a new nonce if there is none in cache
|
|
|
|
if not self.nonce:
|
2019-04-04 15:12:55 +02:00
|
|
|
self._request_url(self.directory['newNonce'])
|
2019-03-18 20:58:30 +01:00
|
|
|
|
|
|
|
protected["nonce"] = self.nonce
|
|
|
|
protected["url"] = url
|
|
|
|
if self.algorithm:
|
|
|
|
protected["alg"] = self.algorithm
|
|
|
|
if self.account_id:
|
|
|
|
protected["kid"] = self.account_id
|
2019-03-23 09:46:36 +01:00
|
|
|
protected64 = tools.bytes_to_base64url(json.dumps(protected).encode('utf8'))
|
|
|
|
out = tools.signature_of_str(self.key, '.'.join([protected64, payload64]))
|
2019-03-18 20:58:30 +01:00
|
|
|
data = json.dumps({
|
|
|
|
"protected": protected64,
|
|
|
|
"payload": payload64,
|
2019-03-23 09:46:36 +01:00
|
|
|
"signature": tools.bytes_to_base64url(out),
|
2019-03-18 20:58:30 +01:00
|
|
|
})
|
2019-04-02 10:15:55 +02:00
|
|
|
return self._request_url(url, data, raw_result)
|
2019-03-18 20:58:30 +01:00
|
|
|
|
|
|
|
# @brief send a signed request to authority
|
|
|
|
def _request_acme_endpoint(self, request, payload=None, protected=None, raw_result=False):
|
|
|
|
return self._request_acme_url(self.directory[request], payload, protected, raw_result)
|
|
|
|
|
|
|
|
# @brief register an account over ACME
|
|
|
|
def register_account(self):
|
2019-03-28 09:22:28 +01:00
|
|
|
if self.account_id:
|
|
|
|
# We already have registered with this authority, just return
|
|
|
|
return
|
|
|
|
|
2019-03-18 20:58:30 +01:00
|
|
|
protected = copy.deepcopy(self.account_protected)
|
|
|
|
payload = {
|
2019-03-20 10:34:59 +01:00
|
|
|
"termsOfServiceAgreed": self.tos_agreed,
|
2019-03-18 20:58:30 +01:00
|
|
|
"onlyReturnExisting": False,
|
|
|
|
}
|
|
|
|
if self.contact:
|
|
|
|
payload["contact"] = self.contact
|
|
|
|
code, result, headers = self._request_acme_endpoint("newAccount", payload, protected)
|
|
|
|
if code < 400 and result['status'] == 'valid':
|
|
|
|
self.account_id = headers['Location']
|
|
|
|
if 'meta' in self.directory and 'termsOfService' in self.directory['meta']:
|
2019-04-04 13:15:34 +02:00
|
|
|
log("ToS at {} have been accepted.".format(self.directory['meta']['termsOfService']))
|
|
|
|
log("Account registered and valid on {}.".format(self.ca))
|
2019-03-18 20:58:30 +01:00
|
|
|
else:
|
|
|
|
raise ValueError("Error registering account: {0} {1}".format(code, result))
|
|
|
|
|
|
|
|
# @brief function to fetch certificate using ACME
|
|
|
|
# @param csr the certificate signing request in pyopenssl format
|
|
|
|
# @param domains list of domains in the certificate, first is CN
|
|
|
|
# @param challenge_handlers a dict containing challenge for all given domains
|
2019-03-21 12:18:49 +01:00
|
|
|
# @return the certificate and corresponding ca as a tuple
|
2019-03-18 20:58:30 +01:00
|
|
|
# @note algorithm and parts of the code are from acme-tiny
|
|
|
|
def get_crt_from_csr(self, csr, domains, challenge_handlers):
|
2019-03-23 09:46:36 +01:00
|
|
|
account_thumbprint = tools.bytes_to_base64url(
|
|
|
|
tools.hash_of_str(json.dumps(self.account_protected['jwk'], sort_keys=True, separators=(',', ':'))))
|
2019-03-18 20:58:30 +01:00
|
|
|
|
2019-04-04 13:15:34 +02:00
|
|
|
log("Ordering certificate for {}".format(domains))
|
2019-03-18 20:58:30 +01:00
|
|
|
identifiers = [{'type': 'dns', 'value': domain} for domain in domains]
|
|
|
|
code, order, headers = self._request_acme_endpoint('newOrder', {'identifiers': identifiers})
|
|
|
|
if code >= 400:
|
|
|
|
raise ValueError("Error with certificate order: {0} {1}".format(code, order))
|
|
|
|
|
|
|
|
order_url = headers['Location']
|
|
|
|
authorizations = list()
|
|
|
|
# verify each domain
|
|
|
|
try:
|
|
|
|
for authorizationUrl in order['authorizations']:
|
|
|
|
# get new challenge
|
2019-04-04 15:12:55 +02:00
|
|
|
code, authorization, _ = self._request_acme_url(authorizationUrl)
|
2019-03-18 20:58:30 +01:00
|
|
|
if code >= 400:
|
|
|
|
raise ValueError("Error requesting authorization: {0} {1}".format(code, authorization))
|
|
|
|
|
2019-03-22 12:33:40 +01:00
|
|
|
authorization['_domain'] = "*.{}".format(authorization['identifier']['value']) if \
|
|
|
|
'wildcard' in authorization and authorization['wildcard'] else authorization['identifier']['value']
|
2019-04-02 10:24:58 +02:00
|
|
|
|
|
|
|
if authorization.get('status', 'no-status-found') == 'valid':
|
|
|
|
log("{} has already been authorized".format(authorization['_domain']))
|
|
|
|
continue
|
|
|
|
if authorization['_domain'] not in challenge_handlers:
|
|
|
|
raise ValueError("No challenge handler given for domain: {0}".format(authorization['_domain']))
|
2019-04-04 13:15:34 +02:00
|
|
|
log("Authorizing {0}".format(authorization['_domain']))
|
2019-03-18 20:58:30 +01:00
|
|
|
|
|
|
|
# create the challenge
|
2019-04-02 10:24:58 +02:00
|
|
|
ctype = challenge_handlers[authorization['_domain']].get_challenge_type()
|
|
|
|
matching_challenges = [c for c in authorization['challenges'] if c['type'] == ctype]
|
2019-03-18 20:58:30 +01:00
|
|
|
if len(matching_challenges) == 0:
|
2019-04-02 10:24:58 +02:00
|
|
|
raise ValueError("Error no challenge matching {0} found: {1}".format(ctype, authorization))
|
2019-03-18 20:58:30 +01:00
|
|
|
|
2019-04-02 10:24:58 +02:00
|
|
|
authorization['_challenge'] = matching_challenges[0]
|
|
|
|
if authorization['_challenge'].get('status', 'no-status-found') == 'valid':
|
|
|
|
log("{} has already been authorized using {}".format(authorization['_domain'], ctype))
|
|
|
|
continue
|
2019-03-18 20:58:30 +01:00
|
|
|
|
2019-04-02 10:24:58 +02:00
|
|
|
authorization['_token'] = re.sub(r"[^A-Za-z0-9_\-]", "_", authorization['_challenge']['token'])
|
2019-03-29 12:59:56 +01:00
|
|
|
challenge_handlers[authorization['_domain']].create_challenge(authorization['identifier']['value'],
|
|
|
|
account_thumbprint,
|
|
|
|
authorization['_token'])
|
2019-03-18 20:58:30 +01:00
|
|
|
authorizations.append(authorization)
|
|
|
|
|
2019-03-29 12:59:56 +01:00
|
|
|
# after all challenges are created, start processing authorizations
|
2019-03-18 20:58:30 +01:00
|
|
|
for authorization in authorizations:
|
|
|
|
try:
|
2019-04-04 12:42:57 +02:00
|
|
|
log("Starting verification of {}".format(authorization['_domain']))
|
|
|
|
challenge_handlers[authorization['_domain']].start_challenge(authorization['identifier']['value'],
|
|
|
|
account_thumbprint,
|
|
|
|
authorization['_token'])
|
2019-03-18 20:58:30 +01:00
|
|
|
# notify challenge is met
|
|
|
|
code, challenge_status, _ = self._request_acme_url(authorization['_challenge']['url'], {
|
|
|
|
"keyAuthorization": "{0}.{1}".format(authorization['_token'], account_thumbprint),
|
|
|
|
})
|
|
|
|
# wait for challenge to be verified
|
|
|
|
while code < 400 and challenge_status.get('status') == "pending":
|
|
|
|
time.sleep(5)
|
2019-04-04 15:12:55 +02:00
|
|
|
code, challenge_status, _ = self._request_acme_url(authorization['_challenge']['url'])
|
2019-03-18 20:58:30 +01:00
|
|
|
|
|
|
|
if challenge_status.get('status') == "valid":
|
2019-04-04 13:15:34 +02:00
|
|
|
log("{0} verified".format(authorization['_domain']))
|
2019-03-18 20:58:30 +01:00
|
|
|
else:
|
|
|
|
raise ValueError("{0} challenge did not pass: {1}".format(
|
|
|
|
authorization['_domain'], challenge_status))
|
|
|
|
finally:
|
2019-03-29 12:59:56 +01:00
|
|
|
challenge_handlers[authorization['_domain']].stop_challenge(authorization['identifier']['value'],
|
|
|
|
account_thumbprint,
|
|
|
|
authorization['_token'])
|
2019-03-18 20:58:30 +01:00
|
|
|
finally:
|
|
|
|
# Destroy challenge handlers in reverse order to replay
|
|
|
|
# any saved state information in the handlers correctly
|
|
|
|
for authorization in reversed(authorizations):
|
|
|
|
try:
|
|
|
|
challenge_handlers[authorization['_domain']].destroy_challenge(
|
|
|
|
authorization['identifier']['value'], account_thumbprint, authorization['_token'])
|
2019-03-22 12:33:40 +01:00
|
|
|
except Exception as e:
|
2019-04-04 13:15:34 +02:00
|
|
|
log('Challenge destruction failed: {}'.format(e), error=True)
|
2019-03-18 20:58:30 +01:00
|
|
|
|
|
|
|
# check order status and retry once
|
2019-04-04 15:12:55 +02:00
|
|
|
code, order, _ = self._request_acme_url(order_url)
|
2019-03-18 20:58:30 +01:00
|
|
|
if code < 400 and order.get('status') == 'pending':
|
|
|
|
time.sleep(5)
|
2019-04-04 15:12:55 +02:00
|
|
|
code, order, _ = self._request_acme_url(order_url)
|
2019-03-18 20:58:30 +01:00
|
|
|
if code >= 400:
|
2019-03-21 12:18:49 +01:00
|
|
|
raise ValueError("Order is still not ready to be finalized: {0} {1}".format(code, order))
|
2019-03-18 20:58:30 +01:00
|
|
|
|
|
|
|
# get the new certificate
|
2019-04-04 13:15:34 +02:00
|
|
|
log("Finalizing certificate")
|
2019-03-18 20:58:30 +01:00
|
|
|
code, finalize, _ = self._request_acme_url(order['finalize'], {
|
2019-03-27 21:00:21 +01:00
|
|
|
"csr": tools.bytes_to_base64url(tools.convert_cert_to_der_bytes(csr)),
|
2019-03-18 20:58:30 +01:00
|
|
|
})
|
|
|
|
while code < 400 and (finalize.get('status') == 'pending' or finalize.get('status') == 'processing'):
|
|
|
|
time.sleep(5)
|
2019-04-04 15:12:55 +02:00
|
|
|
code, finalize, _ = self._request_acme_url(order_url)
|
2019-03-18 20:58:30 +01:00
|
|
|
if code >= 400:
|
|
|
|
raise ValueError("Error finalizing certificate: {0} {1}".format(code, finalize))
|
2019-04-04 13:15:34 +02:00
|
|
|
log("Certificate ready!")
|
2019-03-18 20:58:30 +01:00
|
|
|
|
|
|
|
# return certificate
|
2019-04-04 15:12:55 +02:00
|
|
|
code, certificate, _ = self._request_acme_url(finalize['certificate'], raw_result=True)
|
2019-03-18 20:58:30 +01:00
|
|
|
if code >= 400:
|
2019-03-21 12:18:49 +01:00
|
|
|
raise ValueError("Error downloading certificate chain: {0} {1}".format(code, certificate))
|
|
|
|
|
2019-03-22 12:33:40 +01:00
|
|
|
cert_dict = re.match((r'(?P<cert>-----BEGIN CERTIFICATE-----[^\-]+-----END CERTIFICATE-----)\n\n'
|
|
|
|
r'(?P<ca>-----BEGIN CERTIFICATE-----[^\-]+-----END CERTIFICATE-----)?'),
|
2019-04-02 10:15:55 +02:00
|
|
|
certificate, re.DOTALL).groupdict()
|
2019-03-23 09:46:36 +01:00
|
|
|
cert = tools.convert_pem_str_to_cert(cert_dict['cert'])
|
2019-03-21 12:18:49 +01:00
|
|
|
if cert_dict['ca'] is None:
|
|
|
|
ca = tools.download_issuer_ca(cert)
|
|
|
|
else:
|
2019-03-23 09:46:36 +01:00
|
|
|
ca = tools.convert_pem_str_to_cert(cert_dict['ca'])
|
2019-03-21 12:18:49 +01:00
|
|
|
|
|
|
|
return cert, ca
|
2019-03-27 21:00:21 +01:00
|
|
|
|
|
|
|
# @brief function to revoke a certificate using ACME
|
|
|
|
# @param crt certificate to revoke
|
|
|
|
# @param reason (int) optional certificate revoke reason (see https://tools.ietf.org/html/rfc5280#section-5.3.1)
|
|
|
|
def revoke_crt(self, crt, reason=None):
|
|
|
|
payload = {'certificate': tools.bytes_to_base64url(tools.convert_cert_to_der_bytes(crt))}
|
|
|
|
if reason:
|
|
|
|
payload['reason'] = int(reason)
|
|
|
|
code, result, _ = self._request_acme_endpoint("revokeCert", payload)
|
|
|
|
if code < 400:
|
2019-04-04 13:15:34 +02:00
|
|
|
log("Revocation successful")
|
2019-03-27 21:00:21 +01:00
|
|
|
else:
|
|
|
|
raise ValueError("Revocation failed: {}".format(result))
|