1
0
mirror of https://github.com/moepman/acertmgr.git synced 2024-09-27 23:34:46 +02:00
acertmgr/acertmgr/authority/v2.py
Kishi85 c054ecebe9 acertmgr: change the way the issuer CA is fetched
This changes the way the issuer CA is retrieved if no static_ca file is
used. Previously we would always download the CA using the AIA Info but
API v2 provides normally the full chain PEM upon certificate retrieval
and does not need this step. For the APIv2 case we now use the CA
provided with the certificate which required some changes to the basic
handling of CA files. APIv1 has been adapted to this new handling.
APIv2 has a fallback option to the way APIv1 handles it in case no CA
has been provided.
2019-03-21 12:26:32 +01:00

272 lines
12 KiB
Python

#!/usr/bin/env python
# -*- coding: utf-8 -*-
# acertmgr - acme api v2 functions
# Copyright (c) Rudolf Mayerhofer, 2019.
# available under the ISC license, see LICENSE
import copy
import datetime
import json
import re
import time
from cryptography import x509
from cryptography.hazmat.backends import default_backend
from cryptography.hazmat.primitives import hashes, serialization
from cryptography.hazmat.primitives.asymmetric import padding
from acertmgr import tools
from acertmgr.authority.acme import ACMEAuthority as AbstractACMEAuthority
from acertmgr.tools import byte_string_format
try:
from urllib.request import urlopen, Request # Python 3
except ImportError:
from urllib2 import urlopen, Request # Python 2
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']
self.tos_agreed = str(config.get('authority_tos_agreement')).lower() == 'true'
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
_, self.directory, _ = self._request_url(self.ca + '/directory')
self._request_endpoint('newNonce') # cache the first nonce
# @todo: Add support for key-types other than RSA
numbers = key.public_key().public_numbers()
self.algorithm = "RS256"
self.account_protected = {
"alg": self.algorithm,
"jwk": {
"kty": "RSA",
"e": tools.to_json_base64(byte_string_format(numbers.e)),
"n": tools.to_json_base64(byte_string_format(numbers.n)),
},
}
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:
data = data.encode('utf-8')
resp = urlopen(Request(url, data=data, headers=header))
if raw_result:
return resp.getcode(), resp.read(), resp.headers
body = resp.read()
if len(body) > 0:
try:
body = json.loads(body.decode('utf-8'))
except json.JSONDecodeError as e:
raise ValueError('Could not parse non-raw result (expected JSON)', e)
# Store next Replay-Nonce if it is in the header
if 'Replay-Nonce' in resp.headers:
self.nonce = resp.headers['Replay-Nonce']
return resp.getcode(), body, resp.headers
# @brief helper function to make signed requests
def _request_acme_url(self, url, payload=None, protected=None, raw_result=False):
if not payload:
payload = {}
if not protected:
protected = {}
payload64 = tools.to_json_base64(json.dumps(payload).encode('utf8'))
# Request a new nonce if there is none in cache
if not self.nonce:
self._request_endpoint('newNonce')
protected["nonce"] = self.nonce
protected["url"] = url
if self.algorithm:
protected["alg"] = self.algorithm
if self.account_id:
protected["kid"] = self.account_id
protected64 = tools.to_json_base64(json.dumps(protected).encode('utf8'))
pad = padding.PKCS1v15()
out = self.key.sign('.'.join([protected64, payload64]).encode('utf8'), pad, hashes.SHA256())
data = json.dumps({
"protected": protected64,
"payload": payload64,
"signature": tools.to_json_base64(out),
})
try:
return self._request_url(url, data, raw_result)
except IOError as e:
return getattr(e, "code", None), getattr(e, "read", e.__str__)(), {}
finally:
# Dispose of nonce after it was used
self.nonce = None
# @brief send a request to authority
def _request_endpoint(self, request, data=None, raw_result=False):
return self._request_url(self.directory[request], data, raw_result)
# @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):
protected = copy.deepcopy(self.account_protected)
payload = {
"termsOfServiceAgreed": self.tos_agreed,
"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']:
print("ToS at {} have been accepted.".format(self.directory['meta']['termsOfService']))
print("Account registered and valid.".format())
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
# @return the certificate and corresponding ca as a tuple
# @note algorithm and parts of the code are from acme-tiny
def get_crt_from_csr(self, csr, domains, challenge_handlers):
accountkey_json = json.dumps(self.account_protected['jwk'], sort_keys=True, separators=(',', ':'))
account_hash = hashes.Hash(hashes.SHA256(), backend=default_backend())
account_hash.update(accountkey_json.encode('utf8'))
account_thumbprint = tools.to_json_base64(account_hash.finalize())
print("Ordering certificate for {}".format(domains))
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:
valid_times = list()
for authorizationUrl in order['authorizations']:
# get new challenge
code, authorization, _ = self._request_url(authorizationUrl)
if code >= 400:
raise ValueError("Error requesting authorization: {0} {1}".format(code, authorization))
authorization['_domain'] = \
"*.{}".format(authorization['identifier']['value']) \
if 'wildcard' in authorization and authorization['wildcard'] \
else authorization['identifier']['value']
print("Authorizing {0}".format(authorization['_domain']))
# create the challenge
matching_challenges = [c for c in authorization['challenges'] if
c['type'] == challenge_handlers[authorization['_domain']].get_challenge_type()]
if len(matching_challenges) == 0:
raise ValueError("Error no challenge matching {0} found: {1}".format(
challenge_handlers[authorization['_domain']].get_challenge_type(), authorization))
authorization['_challenge'] = matching_challenges[0]
authorization['_token'] = re.sub(r"[^A-Za-z0-9_\-]", "_", authorization['_challenge']['token'])
if authorization['_domain'] not in challenge_handlers:
raise ValueError("No challenge handler given for domain: {0}".format(authorization['_domain']))
valid_times.append(
challenge_handlers[authorization['_domain']].create_challenge(authorization['identifier']['value'],
account_thumbprint,
authorization['_token']))
authorizations.append(authorization)
print("Waiting until challenges are valid ({})".format(",".join([str(x) for x in valid_times])))
for valid_time in valid_times:
while datetime.datetime.now() < valid_time:
time.sleep(1)
for authorization in authorizations:
print("Starting verification of {}".format(authorization['_domain']))
challenge_handlers[authorization['_domain']].start_challenge()
try:
# 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)
code, challenge_status, _ = self._request_url(authorization['_challenge']['url'])
if challenge_status.get('status') == "valid":
print("{0} verified".format(authorization['_domain']))
else:
raise ValueError("{0} challenge did not pass: {1}".format(
authorization['_domain'], challenge_status))
finally:
challenge_handlers[authorization['_domain']].stop_challenge()
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'])
except (KeyboardInterrupt, SystemError, SystemExit):
# Re-raise runtime/system exceptions
raise
except:
pass
# check order status and retry once
code, order, _ = self._request_url(order_url)
if code < 400 and order.get('status') == 'pending':
time.sleep(5)
code, order, _ = self._request_url(order_url)
if code >= 400:
raise ValueError("Order is still not ready to be finalized: {0} {1}".format(code, order))
# get the new certificate
print("Finalizing certificate")
csr_der = csr.public_bytes(serialization.Encoding.DER)
code, finalize, _ = self._request_acme_url(order['finalize'], {
"csr": tools.to_json_base64(csr_der),
})
while code < 400 and (finalize.get('status') == 'pending' or finalize.get('status') == 'processing'):
time.sleep(5)
code, finalize, _ = self._request_url(order_url)
if code >= 400:
raise ValueError("Error finalizing certificate: {0} {1}".format(code, finalize))
print("Certificate ready!")
# return certificate
code, certificate, _ = self._request_url(finalize['certificate'], raw_result=True)
if code >= 400:
raise ValueError("Error downloading certificate chain: {0} {1}".format(code, certificate))
cert_dict = re.match(("(?P<cert>-----BEGIN CERTIFICATE-----[^\-]+-----END CERTIFICATE-----)\n\n"
"(?P<ca>-----BEGIN CERTIFICATE-----[^\-]+-----END CERTIFICATE-----)?"),
certificate.decode('utf-8'), re.DOTALL).groupdict()
cert = x509.load_pem_x509_certificate(cert_dict['cert'].encode('utf-8'), default_backend())
if cert_dict['ca'] is None:
ca = tools.download_issuer_ca(cert)
else:
ca = x509.load_pem_x509_certificate(cert_dict['ca'].encode('utf-8'), default_backend())
return cert, ca