Python code to generate Let's Encrypt certificates

Posted on 16 November 2018 in Python

I spent today writing some Python code to request certificates from Let's Encrypt. I couldn't find much in the way of simple sample code out there, so I thought it would be worth sharing some. It uses the acme Python package, which is part of the certbot client script.

It's worth noting that none of this is useful stuff if you just want to get a Let's Encrypt certificate for your website; scripts like certbot and dehydrated are what you need for that. This code and the explanation below are for people who are building their own systems to manage Let's Encrypt certs (perhaps for a number of websites) or who want a reasonably simple example showing a little more of what happens under the hood.

The process of getting a certificate through Let's Encrypt is interesting. The main function in the sample code is request_cert. It starts like this:

    domain = domain.lower()

This is because asking for a certificate for a mixed-case domain gives a confusing error (UnexpectedUpdate: AuthorizationResource).

The next step is to register a new user on the Let's Encrypt site, represented by a public/private key pair. A real production system would probably have one or more persistent users registered, and use them, but in order to make this sample code self-contained I do every time the function is called:

    print("Generating user key")
    user_key = josepy.JWKRSA(
        key=rsa.generate_private_key(
            public_exponent=65537,
            key_size=KEY_SIZE,
            backend=default_backend()
        )
    )

    print("Connecting to Let's Encrypt on {}".format(DIRECTORY_URL))
    acme = client.Client(DIRECTORY_URL, user_key)
    print("Registering")
    regr = acme.register()
    print("Agreeing to ToS")
    acme.agree_to_tos(regr)

Now that we have a user, we want to prove to Let's Encrypt that this user is authorized to issue certificates for the domain. The way this works is via domain validation; basically, we need to prove that we have control over the domain. Step one is to ask Let's Encrypt for a list of the different ways it will allow us to prove that (known as "challenges"):

    print("Requesting challenges")
    authzr = acme.request_challenges(
        identifier=messages.Identifier(typ=messages.IDENTIFIER_FQDN, value=domain)
    )

Let's Encrypt supports various kinds of challenges, but this sample code only handles one of them -- http-01. It's the only one I'm interested in for the stuff I'm working on right now, and is the simplest. So the next step is to find the appropriate challenge object in the list:

    print("Looking for HTTP challenge")
    challenge = get_http_challenge(authzr)

...where get_http_challenge is:

def get_http_challenge(authzr):
    for challenge in authzr.body.challenges:
        if challenge.chall.typ == 'http-01':
            return challenge
    else:
        raise Exception("Could not find an HTTP challenge!")

The way an HTTP-based domain validation works is that Let's Encrypt specify a URL on the domain, and you have to set things up so that some specific content is served up when that URL is accessed. The script prints out the URL and the content so that you can set that up:

    print("You need to set up the challenge response.")
    print("URL: http://{}{}".format(domain, challenge.chall.path))
    print("Content: {}".format(challenge.chall.validation(user_key)))

It then verifies that the appropriate content is indeed visible; this is basically just an HTTP GET using requests from the machine where the script is running -- kind of a pre-flight check to make sure that everything is ready before asking Let's Encrypt to do its stuff.

    response = challenge.chall.response(user_key)
    while not response.simple_verify(challenge.chall, domain, user_key.public_key()):
        raw_input("It doesn't look like it's set up yet; press return when it is.")

Once that's all set up, we ask Let's Encrypt to do the authorization. They will make one or more requests to the challenge URL and confirm that the appropriate response comes back. If it all works, they can be comfortable that the person running the script really does have control of the domain.

    print("Authorizing -- here goes...")
    auth_response = acme.answer_challenge(challenge, challenge.chall.response(user_key))
    print("Response was {}".format(auth_response))

It can take a little while after the answer_challenge for the internal state at Let's Encrypt to settle down, so now we wait until they say that yes, our user is definitely properly authorized for the domain:

    print("Waiting for authorization to become valid")
    while True:
        print("Polling")
        authzr, authzr_response = acme.poll(authzr)
        challenge = get_http_challenge(authzr)
        if challenge.status.name == "valid":
            break
        print("HTTP challenge is currently {}".format(challenge))
        time.sleep(1)
    print("Auth valid")

At this point, Let's Encrypt believe that we control the domain in question. They'll keep that authorization on file for some period of time (I think it's about a week). So now we can ask them for a certificate. First, we generate a private key for the cert:

    print("Generating CSR")
    certificate_key = crypto.PKey()
    certificate_key.generate_key(crypto.TYPE_RSA, 2048)

...and a certificate signing request (CSR) signed with that key:

    csr = crypto.X509Req()
    csr.get_subject().CN = domain
    csr.set_pubkey(certificate_key)
    csr.sign(certificate_key, "sha256")

Then we sent it all off to Let's Encrypt, and hopefully get a certificate back:

    print("Requesting certificate")
    certificate_response = acme.request_issuance(josepy.util.ComparableX509(csr), [authzr])
    print("Got it!")

For a full certificate that we can install, we'll also need any intermediate certs in the chain:

    print("Fetching chain")
    chain = acme.fetch_chain(certificate_response)
    print("Done!")

And we're all set! The last thing to do is print out the certificate key, and a full combined cert for installation on the web server:

    print("Here are the details:")

    print("Private key:")
    print(crypto.dump_privatekey(FILETYPE_PEM, certificate_key))

    print("Combined cert:")
    print(crypto.dump_certificate(FILETYPE_PEM, certificate_response.body.wrapped))
    for cert in chain:
        print(crypto.dump_certificate(FILETYPE_PEM, cert.wrapped))

I hope that's of some use to someone else out there :-)