1
0
mirror of https://github.com/certbot/certbot.git synced 2026-01-21 19:01:07 +03:00

works with boulder

This commit is contained in:
James Kasten
2015-04-10 23:02:01 -07:00
parent f5d25c392b
commit 37620ebe39
8 changed files with 182 additions and 134 deletions

View File

@@ -5,24 +5,20 @@ import hashlib
import Crypto.Random
from letsencrypt.acme import fields
from letsencrypt.acme import jose
from letsencrypt.acme import messages2
from letsencrypt.acme import other
# pylint: disable=too-few-public-methods
class Challenge(jose.TypedJSONObjectWithFields):
# _fields_to_json | pylint: disable=abstract-method
"""ACME challenge."""
TYPES = {}
class ContinuityChallenge(Challenge): # pylint: disable=abstract-method
class ContinuityChallenge(messages2.Challenge): # pylint: disable=abstract-method
"""Client validation challenges."""
class DVChallenge(Challenge): # pylint: disable=abstract-method
class DVChallenge(messages2.Challenge): # pylint: disable=abstract-method
"""Domain validation challenges."""
@@ -41,7 +37,7 @@ class ChallengeResponse(jose.TypedJSONObjectWithFields):
return super(ChallengeResponse, cls).from_json(jobj)
@Challenge.register
@messages2.Challenge.register
class SimpleHTTPS(DVChallenge):
"""ACME "simpleHttps" challenge."""
typ = "simpleHttps"
@@ -69,7 +65,7 @@ class SimpleHTTPSResponse(ChallengeResponse):
return self.URI_TEMPLATE.format(domain=domain, path=self.path)
@Challenge.register
@messages2.Challenge.register
class DVSNI(DVChallenge):
"""ACME "dvsni" challenge.
@@ -93,6 +89,9 @@ class DVSNI(DVChallenge):
nonce = jose.Field("nonce", encoder=binascii.hexlify,
decoder=functools.partial(functools.partial(
jose.decode_hex16, size=NONCE_SIZE)))
uri = jose.Field('uri')
status = jose.Field('status', decoder=messages2.Status.from_json)
validated = fields.RFC3339Field('validated', omitempty=True)
@property
def nonce_domain(self):
@@ -138,7 +137,7 @@ class DVSNIResponse(ChallengeResponse):
"""Domain name for certificate subjectAltName."""
return self.z(chall) + self.DOMAIN_SUFFIX
@Challenge.register
@messages2.Challenge.register
class RecoveryContact(ContinuityChallenge):
"""ACME "recoveryContact" challenge."""
typ = "recoveryContact"
@@ -147,6 +146,10 @@ class RecoveryContact(ContinuityChallenge):
success_url = jose.Field("successURL", omitempty=True)
contact = jose.Field("contact", omitempty=True)
uri = jose.Field('uri')
status = jose.Field('status', decoder=messages2.Status.from_json)
validated = fields.RFC3339Field('validated', omitempty=True)
@ChallengeResponse.register
class RecoveryContactResponse(ChallengeResponse):
@@ -155,11 +158,15 @@ class RecoveryContactResponse(ChallengeResponse):
token = jose.Field("token", omitempty=True)
@Challenge.register
@messages2.Challenge.register
class RecoveryToken(ContinuityChallenge):
"""ACME "recoveryToken" challenge."""
typ = "recoveryToken"
uri = jose.Field('uri')
status = jose.Field('status', decoder=messages2.Status.from_json)
validated = fields.RFC3339Field('validated', omitempty=True)
@ChallengeResponse.register
class RecoveryTokenResponse(ChallengeResponse):
@@ -168,7 +175,7 @@ class RecoveryTokenResponse(ChallengeResponse):
token = jose.Field("token", omitempty=True)
@Challenge.register
@messages2.Challenge.register
class ProofOfPossession(ContinuityChallenge):
"""ACME "proofOfPossession" challenge.
@@ -180,6 +187,10 @@ class ProofOfPossession(ContinuityChallenge):
NONCE_SIZE = 16
uri = jose.Field('uri')
status = jose.Field('status', decoder=messages2.Status.from_json)
validated = fields.RFC3339Field('validated', omitempty=True)
class Hints(jose.JSONObjectWithFields):
"""Hints for "proofOfPossession" challenge.
@@ -236,12 +247,15 @@ class ProofOfPossessionResponse(ChallengeResponse):
return self.signature.verify(self.nonce)
@Challenge.register
@messages2.Challenge.register
class DNS(DVChallenge):
"""ACME "dns" challenge."""
typ = "dns"
token = jose.Field("token")
uri = jose.Field('uri')
status = jose.Field('status', decoder=messages2.Status.from_json)
validated = fields.RFC3339Field('validated', omitempty=True)
@ChallengeResponse.register
class DNSResponse(ChallengeResponse):

View File

@@ -216,7 +216,7 @@ class JSONObjectWithFields(util.ImmutableMap, interfaces.JSONDeSerializable):
value = getattr(self, slot)
if field.omit(value):
logging.debug('Ommiting empty field "%s" (%s)', slot, value)
logging.debug('Omitting empty field "%s" (%s)', slot, value)
else:
try:
jobj[field.json_name] = field.encode(value)
@@ -246,6 +246,7 @@ class JSONObjectWithFields(util.ImmutableMap, interfaces.JSONDeSerializable):
"""Deserialize fields from JSON."""
cls._check_required(jobj)
fields = {}
for slot, field in cls._fields.iteritems():
if field.json_name not in jobj and field.omitempty:
fields[slot] = field.default
@@ -372,17 +373,15 @@ class TypedJSONObjectWithFields(JSONObjectWithFields):
raise errors.DeserializationError("missing type field")
try:
type_cls = cls.TYPES[typ]
return cls.TYPES[typ]
except KeyError:
raise errors.UnrecognizedTypeError(typ, jobj)
return type_cls
def to_json(self):
"""Get JSON serializable object.
:returns: Serializable JSON object representing ACME typed object.
:meth:`validate` will almost certianly not work, due to reasons
:meth:`validate` will almost certainly not work, due to reasons
explained in :class:`letsencrypt.acme.interfaces.IJSONSerializable`.
:rtype: dict

View File

@@ -115,6 +115,14 @@ class ResourceBody(jose.JSONObjectWithFields):
"""ACME Resource Body."""
class TypedResourceBody(jose.TypedJSONObjectWithFields):
"""ACME Resource Body with type."""
class ResourceBody(jose.JSONObjectWithFields):
"""ACME Resource Body"""
class RegistrationResource(Resource):
"""Registration Resource.
@@ -130,7 +138,7 @@ class Registration(ResourceBody):
"""Registration Resource Body.
:ivar letsencrypt.acme.jose.jwk.JWK key: Public key.
:ivar tuple contact:
:ivar tuple contact: Contact information following ACME spec
"""
@@ -158,41 +166,23 @@ class ChallengeResource(Resource, jose.JSONObjectWithFields):
return self.body.uri
class ChallengeBody(ResourceBody):
class Challenge(TypedResourceBody):
"""Challenge Resource Body.
.. todo::
Confusingly, this has a similar name to `.challenges.Challenge`,
as well as `.achallenges.AnnotatedChallenge` or
`.achallenges.Indexed`... Once `messages2` and `network2` is
integrated with the rest of the client, this class functionality
will be merged with `.challenges.Challenge`. Meanwhile,
separation allows the ``master`` to be still interoperable with
Node.js server (protocol v00). For the time being use names such
as ``challb`` to distinguish instances of this class from
``achall`` or ``ichall``.
:ivar letsencrypt.acme.messages2.Status status:
:ivar datetime.datetime validated:
"""
__slots__ = ('chall',)
TYPES = {}
# __slots__ = ('chall',)
uri = jose.Field('uri')
status = jose.Field('status', decoder=Status.from_json)
validated = fields.RFC3339Field('validated', omitempty=True)
def to_json(self):
jobj = super(ChallengeBody, self).to_json()
jobj.update(self.chall.to_json())
jobj = super(Challenge, self).to_json()
return jobj
@classmethod
def fields_from_json(cls, jobj):
jobj_fields = super(ChallengeBody, cls).fields_from_json(jobj)
jobj_fields['chall'] = challenges.Challenge.from_json(jobj)
return jobj_fields
class AuthorizationResource(Resource):
"""Authorization Resource.
@@ -208,7 +198,7 @@ class Authorization(ResourceBody):
"""Authorization Resource Body.
:ivar letsencrypt.acme.messages2.Identifier identifier:
:ivar list challenges: `list` of `Challenge`
:ivar list challenges: `list` of `ChallengeBody`
:ivar tuple combinations: Challenge combinations (`tuple` of `tuple`
of `int`, as opposed to `list` of `list` from the spec).
:ivar letsencrypt.acme.jose.jwk.JWK key: Public key.
@@ -235,7 +225,7 @@ class Authorization(ResourceBody):
@challenges.decoder
def challenges(value): # pylint: disable=missing-docstring,no-self-argument
return tuple(ChallengeBody.from_json(chall) for chall in value)
return tuple(challenges.Challenge.from_json(chall) for chall in value)
@property
def resolved_combinations(self):

View File

@@ -1,7 +1,6 @@
"""Client annotated ACME challenges.
Please use names such as ``achall`` and ``ichall`` (respectively ``achalls``
and ``ichalls`` for collections) to distiguish from variables "of type"
Please use names such as ``achall`` to distiguish from variables "of type"
:class:`letsencrypt.acme.challenges.Challenge` (denoted by ``chall``)::
from letsencrypt.acme import challenges
@@ -9,11 +8,10 @@ and ``ichalls`` for collections) to distiguish from variables "of type"
chall = challenges.DNS(token='foo')
achall = achallenges.DNS(chall=chall, domain='example.com')
ichall = achallenges.Indexed(achall=achall, index=0)
Note, that all annotated challenges act as a proxy objects::
ichall.token == achall.token == chall.token
achall.token == chall.token
"""
from letsencrypt.acme import challenges
@@ -86,17 +84,3 @@ class ProofOfPossession(AnnotatedChallenge):
"""Client annotated "proofOfPossession" ACME challenge."""
__slots__ = ('chall', 'domain')
acme_type = challenges.ProofOfPossession
class Indexed(jose_util.ImmutableMap):
"""Indexed and annotated ACME challenge.
Wraps around :class:`AnnotatedChallenge` and annotates with an
``index`` in order to maintain the proper position of the response
within a larger challenge list.
"""
__slots__ = ('achall', 'index')
def __getattr__(self, name):
return getattr(self.achall, name)

View File

@@ -2,6 +2,7 @@
import itertools
import logging
import sys
import time
import Crypto.PublicKey.RSA
@@ -51,7 +52,7 @@ class AuthHandler(object): # pylint: disable=too-many-instance-attributes
self.dv_c = []
self.cont_c = []
def get_authorizations(self, domains):
def get_authorizations(self, domains, new_authz_uri):
"""Retrieve all authorizations for challenges.
:param set domains: Domains for authorization
@@ -64,6 +65,9 @@ class AuthHandler(object): # pylint: disable=too-many-instance-attributes
authorizations
"""
for domain in domains:
self.authzr[domain] = self.network.request_domain_challenges(
domain, new_authz_uri)
self._choose_challenges(domains)
cont_resp, dv_resp = self._get_responses()
logging.info("Ready for verification...")
@@ -71,13 +75,15 @@ class AuthHandler(object): # pylint: disable=too-many-instance-attributes
# Send all Responses
self._respond(cont_resp, dv_resp)
return self._verify_auths()
def _choose_challenges(self, domains):
logging.info("Performing the following challenges:")
for dom in domains:
path = gen_challenge_path(
self.authzr[dom].challenges,
self.authzr[dom].body.challenges,
self._get_chall_pref(dom),
self.authzr[dom].combinations)
self.authzr[dom].body.combinations)
dom_dv_c, dom_cont_c = self._challenge_factory(
dom, path)
@@ -106,51 +112,65 @@ class AuthHandler(object): # pylint: disable=too-many-instance-attributes
return cont_resp, dv_resp
def acme_authorization(self, domain):
"""Handle ACME "authorization" phase.
def _verify_auths(self):
time.sleep(6)
for domain in self.authzr:
self.authzr[domain], resp = self.network.poll(self.authzr[domain])
if self.authzr[domain].body.status == messages2.STATUS_INVALID:
raise errors.AuthHandlerError(
"Unable to retrieve authorization for %s" % domain)
:param str domain: domain that is requesting authorization
:returns: ACME "authorization" message.
:rtype: :class:`letsencrypt.acme.messages.Authorization`
"""
try:
auth = self.network.send_and_receive_expected(
messages.AuthorizationRequest.create(
session_id=self.msgs[domain].session_id,
nonce=self.msgs[domain].nonce,
responses=self.responses[domain],
name=domain,
key=jose.HashableRSAKey(Crypto.PublicKey.RSA.importKey(
self.authkey[domain].pem))),
messages.Authorization)
logging.info("Received Authorization for %s", domain)
return auth
except errors.LetsEncryptClientError as err:
logging.fatal(str(err))
logging.fatal(
"Failed Authorization procedure - cleaning up challenges")
sys.exit(1)
finally:
self._cleanup_challenges(domain)
self._cleanup_challenges()
return [self.authzr[domain] for domain in self.authzr]
def _respond(self, cont_resp, dv_resp):
"""Send/Recieve confirmation of all challenges.
"""Send/Receive confirmation of all challenges.
.. note:: This method also cleans up the auth_handler state.
"""
to_check = self._send_responses(self.dv_c, dv_resp)
to_check.update(self._send_responses(self.cont_c, cont_resp))
chall_update = dict()
self._send_responses(self.dv_c, dv_resp, chall_update)
self._send_responses(self.cont_c, cont_resp, chall_update)
def _send_responses(self, achalls, resps):
# self._poll_challenges(chall_update)
def _send_responses(self, achalls, resps, chall_update):
"""Send responses and make sure errors are handled."""
to_check = dict()
for achall, resp in itertools.izip(achalls, resps):
if resp:
to_check[achall.domain] = self.network.answer_challenge(
achall.chall, resp)
challr = self.network.answer_challenge(achall.chall, resp)
chall_update[achall.domain] = chall_update.get(
achall.domain, []).append(challr)
# def _poll_challenges(self, chall_update):
# to_check = chall_update.keys()
# completed = []
# while to_check:
#
# def _handle_to_check(self):
# for domain in to_check:
# self.authzr[domain] = self.network.poll(self.authzr[domain])
# if self.authzr[domain].status == messages2.STATUS_VALID:
# completed.append(domain)
# if self.authzr[domain].status == messages2.STATUS_INVALID:
# logging.error("Failed authorization for %s", domain)
# raise errors.AuthHandlerError(
# "Failed Authorization for %s" % domain)
# for challr in chall_update[domain]:
# status = self._get_status_of_chall(self.authzr[domain], challr)
# if status == messages2.STATUS_VALID:
# chall_update[domain].remove(challr)
# elif status == messages2.STATUS_INVALID:
# raise errors.AuthHandlerError(
# "Failed %s challenge for domain %s" % (
# challr.body.chall.typ, domain))
#
# def _get_status_of_chall(self, authzr, challr):
# for challb in authzr.challenges:
# # TODO: Use better identifiers... instead of type
# if isinstance(challb.chall, challr.body.chall):
# return challb.status
def _get_chall_pref(self, domain):
"""Return list of challenge preferences.
@@ -192,16 +212,16 @@ class AuthHandler(object): # pylint: disable=too-many-instance-attributes
cont_chall = []
for index in path:
chall = self.authzr[domain].challenges[index]
chall = self.authzr[domain].body.challenges[index]
if isinstance(chall, challenges.DVSNI):
logging.info(" DVSNI challenge for %s.", domain)
achall = achallenges.DVSNI(
chall=chall, domain=domain, key=self.authkey[domain])
chall=chall, domain=domain, key=self.authkey)
elif isinstance(chall, challenges.SimpleHTTPS):
logging.info(" SimpleHTTPS challenge for %s.", domain)
achall = achallenges.SimpleHTTPS(
chall=chall, domain=domain, key=self.authkey[domain])
chall=chall, domain=domain, key=self.authkey)
elif isinstance(chall, challenges.DNS):
logging.info(" DNS challenge for %s.", domain)
achall = achallenges.DNS(chall=chall, domain=domain)
@@ -211,7 +231,8 @@ class AuthHandler(object): # pylint: disable=too-many-instance-attributes
achall = achallenges.RecoveryToken(chall=chall, domain=domain)
elif isinstance(chall, challenges.RecoveryContact):
logging.info(" Recovery Contact Challenge for %s.", domain)
achall = achallenges.RecoveryContact(chall=chall, domain=domain)
achall = achallenges.RecoveryContact(
chall=chall, domain=domain)
elif isinstance(chall, challenges.ProofOfPossession):
logging.info(" Proof-of-Possession Challenge for %s", domain)
achall = achallenges.ProofOfPossession(
@@ -219,14 +240,13 @@ class AuthHandler(object): # pylint: disable=too-many-instance-attributes
else:
raise errors.LetsEncryptClientError(
"Received unsupported challenge of type: %s", chall.typ)
ichall = achallenges.Indexed(achall=achall, index=index)
"Received unsupported challenge of type: %s",
chall.typ)
if isinstance(chall, challenges.ContinuityChallenge):
cont_chall.append(ichall)
cont_chall.append(achall)
elif isinstance(chall, challenges.DVChallenge):
dv_chall.append(ichall)
dv_chall.append(achall)
return dv_chall, cont_chall

View File

@@ -65,8 +65,9 @@ class Client(object):
# TODO: Allow for other alg types besides RS256
self.network = network2.Network(
config.server+"/acme/new-registration",
"https://%s/acme/new-reg" % config.server,
jwk.JWKRSA.load(authkey.pem))
self.config = config
if dv_auth is not None:
@@ -88,9 +89,18 @@ class Client(object):
"mailto:" + email if email is not None else None,
"tel:" + phone if phone is not None else None
)
contact_tuple = tuple(detail for detail in details if detail is not None)
self.regr = self.network.register(
tuple(detail for detail in details if detail is not None))
# TODO: Replace with real info once through testing.
if not contact_tuple:
contact_tuple = ("mailto:letsencrypt-client@letsencrypt.org",
"tel:+12025551212")
self.regr = self.network.register(contact=contact_tuple)
# If terms of service exist... we need to sign it.
# TODO: Replace the `preview EULA` with this...
if self.regr.terms_of_service:
self.network.agree_to_tos(self.regr)
def set_regr(self, regr):
"""Set a preexisting registration resource."""
@@ -122,21 +132,26 @@ class Client(object):
# Perform Challenges/Get Authorizations
if self.regr.new_authzr_uri:
self.auth_handler.get_authorizations(domains, self.regr)
authzr = self.auth_handler.get_authorizations(
domains, self.regr.new_authzr_uri)
else:
self.auth_handler.get_authorizations(
domains, self.config.server + "/acme/new-authorization")
authzr = self.auth_handler.get_authorizations(
domains,
"https://%s/acme/new-authz" % self.config.server)
# Create CSR from names
if csr is None:
csr = init_csr(self.authkey, domains, self.config.cert_dir)
# Retrieve certificate
certificate_msg = self.acme_certificate(csr.data)
certr = self.network.request_issuance(
jose.ComparableX509(
M2Crypto.X509.load_request_der_string(csr.data)),
authzr)
# Save Certificate
cert_file, chain_file = self.save_certificate(
certificate_msg, self.config.cert_path, self.config.chain_path)
certr, self.config.cert_path, self.config.chain_path)
revoker.Revoker.store_cert_key(
cert_file, self.authkey.file, self.config)
@@ -172,12 +187,12 @@ class Client(object):
self.authkey.pem))),
messages.Certificate)
def save_certificate(self, certificate_msg, cert_path, chain_path):
def save_certificate(self, certr, cert_path, chain_path):
# pylint: disable=no-self-use
"""Saves the certificate received from the ACME server.
:param certificate_msg: ACME "certificate" message from server.
:type certificate_msg: :class:`letsencrypt.acme.messages.Certificate`
:param certr: ACME "certificate" resource.
:type certr: :class:`letsencrypt.acme.messages.Certificate`
:param str cert_path: Path to attempt to save the cert file
:param str chain_path: Path to attempt to save the chain file
@@ -188,17 +203,19 @@ class Client(object):
:raises IOError: If unable to find room to write the cert files
"""
# try finally close
cert_chain_abspath = None
cert_fd, cert_file = le_util.unique_file(cert_path, 0o644)
cert_fd.write(certificate_msg.certificate.as_pem())
cert_fd.write(certr.body.as_pem())
cert_fd.close()
logging.info(
"Server issued certificate; certificate written to %s", cert_file)
if certificate_msg.chain:
if certr.cert_chain_uri:
# try finally close
chain_cert = self.network.fetch_chain(certr.cert_chain_uri)
chain_fd, chain_fn = le_util.unique_file(chain_path, 0o644)
for cert in certificate_msg.chain:
chain_fd.write(cert.to_pem())
chain_fd.write(chain_cert.to_pem())
chain_fd.close()
logging.info("Cert chain written to %s", chain_fn)

View File

@@ -50,6 +50,7 @@ class Network(object):
"""
dumps = obj.json_dumps()
logging.debug('Serialized JSON: %s', dumps)
print "json_dumps:", dumps
return jose.JWS.sign(
payload=dumps, key=self.key, alg=self.alg).json_dumps()
@@ -74,7 +75,6 @@ class Network(object):
"""
response_ct = response.headers.get('Content-Type')
try:
# TODO: response.json() is called twice, once here, and
# once in _get and _post clients
@@ -83,6 +83,9 @@ class Network(object):
jobj = None
if not response.ok:
print response
print response.headers
print response.content
if jobj is not None:
if response_ct != cls.JSON_ERROR_CONTENT_TYPE:
logging.debug(
@@ -167,7 +170,7 @@ class Network(object):
'contact'].default):
"""Register.
:param contact: Contact list, as accepted by `.RegistrationResource`
:param contact: Contact list, as accepted by `.Registration`
:type contact: `tuple`
:returns: Registration Resource.
@@ -213,6 +216,21 @@ class Network(object):
raise errors.UnexpectedUpdate(regr)
return updated_regr
def agree_to_tos(self, regr):
"""Agree to the terms-of-service.
Agree to the terms-of-service in a Registration Resource.
:param regr: Registration Resource.
:type regr: `.RegistrationResource`
:returns: Updated Registration Resource.
:rtype: `.RegistrationResource`
"""
self.update_registration(
regr.update(body=regr.body.update(agreement=regr.terms_of_service)))
def _authzr_from_response(self, response, identifier,
uri=None, new_cert_uri=None):
if new_cert_uri is None:
@@ -279,20 +297,23 @@ class Network(object):
:raises errors.UnexpectedUpdate:
"""
print "sendinging challenge to:", challb.uri
response = self._post(challb.uri, self._wrap_in_jws(response))
try:
authzr_uri = response.links['up']['url']
except KeyError:
raise errors.NetworkError('"up" Link header missing')
challr = messages2.ChallengeResource(
# TODO: Right now Boulder responds with the authorization resource
# instead of a challenge resource... this can be uncommented
# once the error is fixed.
return challb
# raise errors.NetworkError('"up" Link header missing')
challr2 = messages2.ChallengeResource(
authzr_uri=authzr_uri,
body=messages2.ChallengeBody.from_json(response.json()))
# TODO: check that challr.uri == response.headers['Location']?
if challr.uri != challb.uri:
raise errors.UnexpectedUpdate(challr.uri)
return challr
def poll_challenge(self, chall):
if challr2.uri != challb.uri:
raise errors.UnexpectedUpdate(challb.uri)
return challr2
@classmethod
def retry_after(cls, response, default):
@@ -352,6 +373,8 @@ class Network(object):
"""
assert authzrs, "Authorizations list is empty"
logging.debug("Requesting issuance...")
print "Requesting issuance: ", authzrs[0]
# TODO: assert len(authzrs) == number of SANs
req = messages2.CertificateRequest(
@@ -402,7 +425,7 @@ class Network(object):
:rtype: `tuple`
"""
# priority queue with datetime (based od Retry-After) as key,
# priority queue with datetime (based on Retry-After) as key,
# and original Authorization Resource as value
waiting = [(datetime.datetime.now(), authzr) for authzr in authzrs]
# mapping between original Authorization Resource and the most

View File

@@ -57,7 +57,7 @@ def create_parser():
config_help = lambda name: interfaces.IConfig[name].__doc__
add("-d", "--domains", metavar="DOMAIN", nargs="+")
add("-s", "--server", default="letsencrypt-demo.org:443",
add("-s", "--server", default="www.letsencrypt-demo.org",
help=config_help("server"))
add("-k", "--authkey", type=read_file,
@@ -202,6 +202,7 @@ def main(): # pylint: disable=too-many-branches, too-many-statements
# but this code should be safe on all environments.
cert_file = None
if auth is not None:
acme.register()
cert_file, chain_file = acme.obtain_certificate(doms)
if installer is not None and cert_file is not None:
acme.deploy_certificate(doms, authkey, cert_file, chain_file)