github.com/letsencrypt/boulder@v0.20251208.0/test/chisel2.py (about)

     1  """
     2  A simple client that uses the Python ACME library to run a test issuance against
     3  a local Boulder server.
     4  Usage:
     5  
     6  $ virtualenv venv
     7  $ . venv/bin/activate
     8  $ pip install -r requirements.txt
     9  $ python chisel2.py foo.com bar.com
    10  """
    11  import json
    12  import logging
    13  import os
    14  import sys
    15  import signal
    16  import threading
    17  import time
    18  
    19  from cryptography.hazmat.backends import default_backend
    20  from cryptography.hazmat.primitives.asymmetric import rsa
    21  from cryptography import x509
    22  from cryptography.hazmat.primitives import hashes
    23  
    24  import OpenSSL
    25  import josepy
    26  
    27  from acme import challenges
    28  from acme import client as acme_client
    29  from acme import crypto_util as acme_crypto_util
    30  from acme import errors as acme_errors
    31  from acme import messages
    32  from acme import standalone
    33  
    34  logging.basicConfig()
    35  logger = logging.getLogger()
    36  logger.setLevel(int(os.getenv('LOGLEVEL', 20)))
    37  
    38  DIRECTORY_V2 = os.getenv('DIRECTORY_V2', 'http://boulder.service.consul:4001/directory')
    39  ACCEPTABLE_TOS = os.getenv('ACCEPTABLE_TOS',"https://boulder.service.consul:4431/terms/v7")
    40  PORT = os.getenv('PORT', '80')
    41  
    42  os.environ.setdefault('REQUESTS_CA_BUNDLE', 'test/certs/ipki/minica.pem')
    43  
    44  import challtestsrv
    45  challSrv = challtestsrv.ChallTestServer()
    46  
    47  def uninitialized_client(key=None):
    48      if key is None:
    49          key = josepy.JWKRSA(key=rsa.generate_private_key(65537, 2048, default_backend()))
    50      net = acme_client.ClientNetwork(key, user_agent="Boulder integration tester")
    51      directory = messages.Directory.from_json(net.get(DIRECTORY_V2).json())
    52      return acme_client.ClientV2(directory, net)
    53  
    54  def make_client(email=None):
    55      """Build an acme.Client and register a new account with a random key."""
    56      client = uninitialized_client()
    57      tos = client.directory.meta.terms_of_service
    58      if tos == ACCEPTABLE_TOS:
    59          client.net.account = client.new_account(messages.NewRegistration.from_data(email=email,
    60              terms_of_service_agreed=True))
    61      else:
    62          raise Exception("Unrecognized terms of service URL %s" % tos)
    63      return client
    64  
    65  class NoClientError(ValueError):
    66      """
    67      An error that occurs when no acme.Client is provided to a function that
    68      requires one.
    69      """
    70      pass
    71  
    72  class EmailRequiredError(ValueError):
    73      """
    74      An error that occurs when a None email is provided to update_email.
    75      """
    76  
    77  def update_email(client, email):
    78      """
    79      Use a provided acme.Client to update the client's account to the specified
    80      email.
    81      """
    82      if client is None:
    83          raise(NoClientError("update_email requires a valid acme.Client argument"))
    84      if email is None:
    85          raise(EmailRequiredError("update_email requires an email argument"))
    86      if not email.startswith("mailto:"):
    87          email = "mailto:"+ email
    88      acct = client.net.account
    89      updatedAcct = acct.update(body=acct.body.update(contact=(email,)))
    90      return client.update_registration(updatedAcct)
    91  
    92  
    93  def get_chall(authz, typ):
    94      for chall_body in authz.body.challenges:
    95          if isinstance(chall_body.chall, typ):
    96              return chall_body
    97      raise Exception("No %s challenge found" % typ.typ)
    98  
    99  def get_any_supported_chall(authz):
   100      """
   101      Return the first supported challenge from the given authorization.
   102      Supports HTTP01 and DNS01.
   103  
   104      Note: DNS-ACCOUNT-01 challenge type is excluded from the list of supported
   105      challenge types until the Python ACME library adds support for it.
   106      """
   107      for chall_body in authz.body.challenges:
   108          if isinstance(chall_body.chall, (challenges.HTTP01, challenges.DNS01)):
   109              return chall_body
   110      raise Exception("No supported challenge types found in authorization")
   111  
   112  def make_csr(domains):
   113      key = OpenSSL.crypto.PKey()
   114      key.generate_key(OpenSSL.crypto.TYPE_RSA, 2048)
   115      pem = OpenSSL.crypto.dump_privatekey(OpenSSL.crypto.FILETYPE_PEM, key)
   116      return acme_crypto_util.make_csr(pem, domains, False)
   117  
   118  def http_01_answer(client, chall_body):
   119      """Return an HTTP01Resource to server in response to the given challenge."""
   120      response, validation = chall_body.response_and_validation(client.net.key)
   121      return standalone.HTTP01RequestHandler.HTTP01Resource(
   122            chall=chall_body.chall, response=response,
   123            validation=validation)
   124  
   125  def auth_and_issue(domains, chall_type="dns-01", email=None, cert_output=None, client=None):
   126      """Make authzs for each of the given domains, set up a server to answer the
   127         challenges in those authzs, tell the ACME server to validate the challenges,
   128         then poll for the authzs to be ready and issue a cert."""
   129      if client is None:
   130          client = make_client(email)
   131  
   132      csr_pem = make_csr(domains)
   133      order = client.new_order(csr_pem)
   134      authzs = order.authorizations
   135  
   136      if chall_type == "http-01":
   137          cleanup = do_http_challenges(client, authzs)
   138      elif chall_type == "dns-01":
   139          cleanup = do_dns_challenges(client, authzs)
   140      else:
   141          raise Exception("invalid challenge type %s" % chall_type)
   142  
   143      # Make up to three attempts, retrying on badNonce errors
   144      for n in range(3):
   145          time.sleep(0.2 * n)  # No sleep before the first attempt, then backoff
   146          try:
   147              order = client.poll_and_finalize(order)
   148              if cert_output is not None:
   149                  with open(cert_output, "w") as f:
   150                      f.write(order.fullchain_pem)
   151          except messages.Error as e:
   152              if e.typ == "urn:ietf:params:acme:error:badNonce":
   153                  continue
   154          else:
   155              break
   156          finally:
   157              cleanup()
   158  
   159      return order
   160  
   161  def do_dns_challenges(client, authzs):
   162      cleanup_hosts = []
   163      for a in authzs:
   164          c = get_chall(a, challenges.DNS01)
   165          name, value = (c.validation_domain_name(a.body.identifier.value),
   166              c.validation(client.net.key))
   167          cleanup_hosts.append(name)
   168          challSrv.add_dns01_response(name, value)
   169          client.answer_challenge(c, c.response(client.net.key))
   170      def cleanup():
   171          for host in cleanup_hosts:
   172              challSrv.remove_dns01_response(host)
   173      return cleanup
   174  
   175  def do_http_challenges(client, authzs):
   176      cleanup_tokens = []
   177      challs = [get_chall(a, challenges.HTTP01) for a in authzs]
   178  
   179      for chall_body in challs:
   180          # Determine the token and key auth for the challenge
   181          token = chall_body.chall.encode("token")
   182          resp = chall_body.response(client.net.key)
   183          keyauth = resp.key_authorization
   184  
   185          # Add the HTTP-01 challenge response for this token/key auth to the
   186          # challtestsrv
   187          challSrv.add_http01_response(token, keyauth)
   188          cleanup_tokens.append(token)
   189  
   190          # Then proceed initiating the challenges with the ACME server
   191          client.answer_challenge(chall_body, chall_body.response(client.net.key))
   192  
   193      def cleanup():
   194          # Cleanup requires removing each of the HTTP-01 challenge responses for
   195          # the tokens we added.
   196          for token in cleanup_tokens:
   197              challSrv.remove_http01_response(token)
   198      return cleanup
   199  
   200  def expect_problem(problem_type, func):
   201      """Run a function. If it raises an acme_errors.ValidationError or messages.Error that
   202         contains the given problem_type, return. If it raises no error or the wrong
   203         error, raise an exception."""
   204      ok = False
   205      try:
   206          func()
   207      except messages.Error as e:
   208          if e.typ == problem_type:
   209              ok = True
   210          else:
   211              raise Exception("Expected %s, got %s" % (problem_type, e.__str__()))
   212      except acme_errors.ValidationError as e:
   213          for authzr in e.failed_authzrs:
   214              for chall in authzr.body.challenges:
   215                  error = chall.error
   216                  if error and error.typ == problem_type:
   217                      ok = True
   218                  elif error:
   219                      raise Exception("Expected %s, got %s" % (problem_type, error.__str__()))
   220      if not ok:
   221          raise Exception('Expected %s, got no error' % problem_type)
   222  
   223  if __name__ == "__main__":
   224      # Die on SIGINT
   225      signal.signal(signal.SIGINT, signal.SIG_DFL)
   226      domains = sys.argv[1:]
   227      if len(domains) == 0:
   228          print(__doc__)
   229          sys.exit(0)
   230      try:
   231          auth_and_issue(domains)
   232      except messages.Error as e:
   233          print(e)
   234          sys.exit(1)