In this multi-part article, you'll learn how to use the Let's Encrypt ACME version 2 API with ** Python ** for ** SSL certificates **.
Let's Encrypt ACME supports two modes using different endpoints. There is a production mode where a real certificate is issued and the rate is limited, and a test staging mode where a test certificate is issued. The production endpoint limits the number of requests that can be made each day. Make sure you are using a staging endpoint while developing software for Let's Encrypt.
Staging Endpoint:https://acme-staging-v02.api.letsencrypt.org/directory
Production Endpoint:https://acme-v02.api.letsencrypt.org/directory
The first API call needs to get the ACME directory. A directory is a list of URLs to call for various commands. The response from the get directory has the following JSON structure.
{
"LPTIN-Jj4u0": "https://community.letsencrypt.org/t/adding-random-entries-to-the-directory/33417",
"keyChange": "https://acme-staging-v02.api.letsencrypt.org/acme/key-change",
"meta": {
"caaIdentities": [
"letsencrypt.org"
],
"termsOfService": "https://letsencrypt.org/documents/LE-SA-v1.2-November-15-2017.pdf",
"website": "https://letsencrypt.org/docs/staging-environment/"
},
"newAccount": "https://acme-staging-v02.api.letsencrypt.org/acme/new-acct",
"newNonce": "https://acme-staging-v02.api.letsencrypt.org/acme/new-nonce",
"newOrder": "https://acme-staging-v02.api.letsencrypt.org/acme/new-order",
"revokeCert": "https://acme-staging-v02.api.letsencrypt.org/acme/revoke-cert"
}
Ignore the first line. ACME generates random keys and values to avoid hard-coding JSON expectations into your code.
Let's take a look at each of the returned data parts.
keyChange This URL is used to change the public key associated with your account. It is used to recover from key compromise.
meta.caaIdentities An array of host names that the ACME server recognizes as referencing itself for validation of the CAA record. The sample code does not use these records.
meta.termsOfService This is the URL that shows the current terms of use. [https://letsencrypt.org/documents/LE-SA-v1.2-November-15-2017.pdf](https://letsencrypt.org/documents/LE-SA-v1.2-November-15- Please take the time to read the reference document regarding the terms of use of 2017.pdf? Spm = a2c65.11461447.0.0.7a192881Lr9stV & file = LE-SA-v1.2-November-15-2017.pdf). In the sample code, the fields that accept the terms of use are set by default.
meta.website A URL to search for websites that provide detailed information about ACME servers. This record is not used in the sample code. Learn more about this record https://letsencrypt.org/docs/staging-environment/
newAccount This is an important API and you need to make a second call when you call the ACME API. nonce is a unique random value that protects against replay attacks. Each API call (except directories) requires a unique nonce value.
newNonce This endpoint will be used to create a new account.
newOrder This endpoint is used to request the issuance of this SSL certificate.
revokeCert This endpoint is used to revoke an existing certificate issued by the same account.
This is an example of the simplest ACME API in the examples package. In this example, we just call the ACME main endpoint. The data returned is a JSON structure that defines the API endpoint as described above. Check the output from this program and become familiar with the various URLs used in most of the examples below.
Source: get_directory.py
""" Let's Encrypt ACME Version 2 Examples - Get Directory"""
# This example will call the ACME API directory and display the returned data
# Reference: https://ietf-wg-acme.github.io/acme/draft-ietf-acme-acme.html#rfc.section.7.1.1
import sys
import requests
import helper
path = 'https://acme-staging-v02.api.letsencrypt.org/directory'
headers = {
'User-Agent': 'neoprime.io-acme-client/1.0',
'Accept-Language': 'en'
}
try:
print('Calling endpoint:', path)
directory = requests.get(path, headers=headers)
except requests.exceptions.RequestException as error:
print(error)
sys.exit(1)
if directory.status_code < 200 or directory.status_code >= 300:
print('Error calling ACME endpoint:', directory.reason)
sys.exit(1)
# The output should be json. If not something is wrong
try:
acme_config = directory.json()
except Exception as ex:
print("Error: Cannot load returned data:", ex)
sys.exit(1)
print('')
print('Returned Data:')
print('****************************************')
print(directory.text)
acme_config = directory.json()
print('')
print('Formatted JSON:')
print('****************************************')
helper.print_dict(acme_config, 0)
The next step is to create a new account on the ACME server. This includes [Part 2](https://www.alibabacloud.com/blog/let%27s-encrypt-acme-with-alibaba-cloud-api-gateway-and-cdn-%E2%80%93- Use the account.key created in part-2_593778? Spm = a2c65.11461447.0.0.7a192881kqOoxn). The ACME server does not track information such as company names in the account database.
In this example, change the EmailAddress parameter to your email address. This example shows how to include multiple email addresses. On the ACME server, entering an email address is optional and not required. The ACME server does not verify your email address.
Let's review some important points about this code.
** 1. Get the ACME directory **
acme_config = get_directory()
** 2. Get the URL of "new Account" **
url = acme_config["newAccount"]
** 3. Request a nonce for the first ACME API call ** After the first ACME API call, a new nonce is returned in the header "Replay-Nonce" after each ACME API call.
nonce = requests.head(acme_config['newNonce']).headers['Replay-Nonce']
** 4. Assemble the HTML header ** The important item is Content-Type: application / jose + json
headers = {
'User-Agent': 'neoprime.io-acme-client/1.0',
'Accept-Language': 'en',
'Content-Type': 'application/jose+json'
}
** 5. Assemble the HTML body with ACME API parameters ** Creating an HTTP body will be explained in detail in Part 4.
payload = {}
payload["termsOfServiceAgreed"] = True
payload["contact"] = EmailAddresses
body_top = {
"alg": "RS256",
"jwk": myhelper.get_jwk(AccountKeyFile),
"url": url,
"nonce": nonce
}
** 6. Assemble the data structure of the HTML body "jose" ** Notice that everything is base64 encoded.
body_top_b64 = myhelper.b64(json.dumps(body_top).encode("utf8"))
payload_b64 = myhelper.b64(json.dumps(payload).encode("utf8"))
jose = {
"protected": body_top_b64,
"payload": payload_b64,
"signature": myhelper.b64(signature)
}
** 7. Finally, call the ACME API. ** ** This is done with an HTTP POST with a JSON body.
resp = requests.post(url, json=jose, headers=headers)
** 8. After the ACME API, two items are returned in the HTTP response header. ** ** Location is the URL of your account.
Replay-Nonce is the "nonce" value for the next ACME API call.
resp.headers['Location']
resp.headers['Replay-Nonce']
Most ACME API calls require the HTTP header to be included.
Content-Type: application/jose+json
** Source: new_account.py **
""" Let's Encrypt ACME Version 2 Examples - New Account"""
# This example will call the ACME API directory and create a new account
# Reference: https://ietf-wg-acme.github.io/acme/draft-ietf-acme-acme.html#rfc.section.7.3.2
import os
import sys
import json
import requests
import myhelper
# Staging URL
path = 'https://acme-staging-v02.api.letsencrypt.org/directory'
# Production URL
# path = 'https://acme-v02.api.letsencrypt.org/directory'
AccountKeyFile = 'account.key'
EmailAddresses = ['mailto:[email protected]', 'mailto:[email protected]']
def check_account_key_file():
""" Verify that the Account Key File exists and prompt to create if it does not exist """
if os.path.exists(AccountKeyFile) is not False:
return True
print('Error: File does not exist: {0}'.format(AccountKeyFile))
if myhelper.Confirm('Create new account private key (y/n): ') is False:
print('Cancelled')
return False
myhelper.create_rsa_private_key(AccountKeyFile)
if os.path.exists(AccountKeyFile) is False:
print('Error: File does not exist: {0}'.format(AccountKeyFile))
return False
return True
def get_directory():
""" Get the ACME Directory """
headers = {
'User-Agent': 'neoprime.io-acme-client/1.0',
'Accept-Language': 'en',
}
try:
print('Calling endpoint:', path)
directory = requests.get(path, headers=headers)
except requests.exceptions.RequestException as error:
print(error)
return False
if directory.status_code < 200 or directory.status_code >= 300:
print('Error calling ACME endpoint:', directory.reason)
print(directory.text)
return False
# The following statements are to understand the output
acme_config = directory.json()
return acme_config
def main():
""" Main Program Function """
headers = {
'User-Agent': 'neoprime.io-acme-client/1.0',
'Accept-Language': 'en',
'Content-Type': 'application/jose+json'
}
if check_account_key_file() is False:
sys.exit(1)
acme_config = get_directory()
if acme_config is False:
sys.exit(1)
url = acme_config["newAccount"]
# Get the URL for the terms of service
terms_service = acme_config.get("meta", {}).get("termsOfService", "")
print('Terms of Service:', terms_service)
nonce = requests.head(acme_config['newNonce']).headers['Replay-Nonce']
print('Nonce:', nonce)
print("")
# Create the account request
payload = {}
if terms_service != "":
payload["termsOfServiceAgreed"] = True
payload["contact"] = EmailAddresses
payload_b64 = myhelper.b64(json.dumps(payload).encode("utf8"))
body_top = {
"alg": "RS256",
"jwk": myhelper.get_jwk(AccountKeyFile),
"url": url,
"nonce": nonce
}
body_top_b64 = myhelper.b64(json.dumps(body_top).encode("utf8"))
data = "{0}.{1}".format(body_top_b64, payload_b64).encode("utf8")
signature = myhelper.sign(data, AccountKeyFile)
#
# Create the HTML request body
#
jose = {
"protected": body_top_b64,
"payload": payload_b64,
"signature": myhelper.b64(signature)
}
try:
print('Calling endpoint:', url)
resp = requests.post(url, json=jose, headers=headers)
except requests.exceptions.RequestException as error:
resp = error.response
print(resp)
except Exception as ex:
print(ex)
except BaseException as ex:
print(ex)
if resp.status_code < 200 or resp.status_code >= 300:
print('Error calling ACME endpoint:', resp.reason)
print('Status Code:', resp.status_code)
myhelper.process_error_message(resp.text)
sys.exit(1)
print('')
if 'Location' in resp.headers:
print('Account URL:', resp.headers['Location'])
else:
print('Error: Response headers did not contain the header "Location"')
main()
sys.exit(0)
Now that you've created an account using account.key, let's communicate with the ACME server to see what information is stored on the server. In this example, instead of hard-coding the configuration parameters into the source code, the configuration file "acme.ini" is introduced.
Modify acme.ini to include certain information such as your email address.
** Source: acme.ini **
[acme-neoprime]
UserAgent = neoprime.io-acme-client/1.0
# [Required] ACME account key
AccountKeyFile = account.key
# Certifcate Signing Request (CSR)
CSRFile = example.com.csr
ChainFile = example.com.chain.pem
# ACME URL
# Staging URL
# https://acme-staging-v02.api.letsencrypt.org/directory
# Production URL
# https://acme-v02.api.letsencrypt.org/directory
ACMEDirectory = https://acme-staging-v02.api.letsencrypt.org/directory
# Email Addresses so that LetsEncrypt can notify about SSL renewals
Contacts = mailto:example.com;mailto:[email protected]
# Preferred Language
Language = en
** Source: get_acount_info.py **
""" Let's Encrypt ACME Version 2 Examples - Get Account Information """
############################################################
# This example will call the ACME API directory and get the account information
# Reference: https://ietf-wg-acme.github.io/acme/draft-ietf-acme-acme.html#rfc.section.7.3.3
#
# This program uses the AccountKeyFile set in acme.ini to return information about the ACME account.
############################################################
import sys
import json
import requests
import helper
import myhelper
############################################################
# Start - Global Variables
g_debug = 0
acme_path = ''
AccountKeyFile = ''
EmailAddresses = []
headers = {}
# End - Global Variables
############################################################
############################################################
# Load the configuration from acme.ini
############################################################
def load_acme_parameters(debug=0):
""" Load the configuration from acme.ini """
global acme_path
global AccountKeyFile
global EmailAddresses
global headers
config = myhelper.load_acme_config(filename='acme.ini')
if debug is not 0:
print(config.get('acme-neoprime', 'accountkeyfile'))
print(config.get('acme-neoprime', 'csrfile'))
print(config.get('acme-neoprime', 'chainfile'))
print(config.get('acme-neoprime', 'acmedirectory'))
print(config.get('acme-neoprime', 'contacts'))
print(config.get('acme-neoprime', 'language'))
acme_path = config.get('acme-neoprime', 'acmedirectory')
AccountKeyFile = config.get('acme-neoprime', 'accountkeyfile')
EmailAddresses = config.get('acme-neoprime', 'contacts').split(';')
headers['User-Agent'] = config.get('acme-neoprime', 'UserAgent')
headers['Accept-Language'] = config.get('acme-neoprime', 'language')
headers['Content-Type'] = 'application/jose+json'
return config
############################################################
#
############################################################
def get_account_url(url, nonce):
""" Get the Account URL based upon the account key """
# Create the account request
payload = {}
payload["termsOfServiceAgreed"] = True
payload["contact"] = EmailAddresses
payload["onlyReturnExisting"] = True
payload_b64 = myhelper.b64(json.dumps(payload).encode("utf8"))
body_top = {
"alg": "RS256",
"jwk": myhelper.get_jwk(AccountKeyFile),
"url": url,
"nonce": nonce
}
body_top_b64 = myhelper.b64(json.dumps(body_top).encode("utf8"))
#
# Create the message digest
#
data = "{0}.{1}".format(body_top_b64, payload_b64).encode("utf8")
signature = myhelper.sign(data, AccountKeyFile)
#
# Create the HTML request body
#
jose = {
"protected": body_top_b64,
"payload": payload_b64,
"signature": myhelper.b64(signature)
}
#
# Make the ACME request
#
try:
print('Calling endpoint:', url)
resp = requests.post(url, json=jose, headers=headers)
except requests.exceptions.RequestException as error:
resp = error.response
print(resp)
except Exception as error:
print(error)
if resp.status_code < 200 or resp.status_code >= 300:
print('Error calling ACME endpoint:', resp.reason)
print('Status Code:', resp.status_code)
myhelper.process_error_message(resp.text)
sys.exit(1)
if 'Location' in resp.headers:
print('Account URL:', resp.headers['Location'])
else:
print('Error: Response headers did not contain the header "Location"')
# Get the nonce for the next command request
nonce = resp.headers['Replay-Nonce']
account_url = resp.headers['Location']
return nonce, account_url
############################################################
#
############################################################
def get_account_info(nonce, url, location):
""" Get the Account Information """
# Create the account request
payload = {}
payload_b64 = myhelper.b64(json.dumps(payload).encode("utf8"))
body_top = {
"alg": "RS256",
"kid": location,
"nonce": nonce,
"url": location
}
body_top_b64 = myhelper.b64(json.dumps(body_top).encode("utf8"))
#
# Create the message digest
#
data = "{0}.{1}".format(body_top_b64, payload_b64).encode("utf8")
signature = myhelper.sign(data, AccountKeyFile)
#
# Create the HTML request body
#
jose = {
"protected": body_top_b64,
"payload": payload_b64,
"signature": myhelper.b64(signature)
}
#
# Make the ACME request
#
try:
print('Calling endpoint:', url)
resp = requests.post(url, json=jose, headers=headers)
except requests.exceptions.RequestException as error:
resp = error.response
print(resp)
except Exception as error:
print(error)
if resp.status_code < 200 or resp.status_code >= 300:
print('Error calling ACME endpoint:', resp.reason)
print('Status Code:', resp.status_code)
myhelper.process_error_message(resp.text)
sys.exit(1)
nonce = resp.headers['Replay-Nonce']
# resp.text is the returned JSON data describing the account
return nonce, resp.text
############################################################
#
############################################################
def load_acme_urls(path):
""" Load the ACME Directory of URLS """
try:
print('Calling endpoint:', path)
resp = requests.get(acme_path, headers=headers)
except requests.exceptions.RequestException as error:
print(error)
sys.exit(1)
if resp.status_code < 200 or resp.status_code >= 300:
print('Error calling ACME endpoint:', resp.reason)
print(resp.text)
sys.exit(1)
return resp.json()
############################################################
#
############################################################
def acme_get_nonce(urls):
""" Get the ACME Nonce that is used for the first request """
global headers
path = urls['newNonce']
try:
print('Calling endpoint:', path)
resp = requests.head(path, headers=headers)
except requests.exceptions.RequestException as error:
print(error)
return False
if resp.status_code < 200 or resp.status_code >= 300:
print('Error calling ACME endpoint:', resp.reason)
print(resp.text)
return False
return resp.headers['Replay-Nonce']
############################################################
# Main Program Function
############################################################
def main(debug=0):
""" Main Program Function """
acme_urls = load_acme_urls(acme_path)
url = acme_urls["newAccount"]
nonce = acme_get_nonce(acme_urls)
if nonce is False:
sys.exit(1)
nonce, account_url = get_account_url(url, nonce)
# resp is the returned JSON data describing the account
nonce, resp = get_account_info(nonce, account_url, account_url)
info = json.loads(resp)
if debug is not 0:
print('')
print('Returned Data:')
print('##################################################')
#print(info)
helper.print_dict(info)
print('##################################################')
print('')
print('ID: ', info['id'])
print('Contact: ', info['contact'])
print('Initial IP:', info['initialIp'])
print('Created At:', info['createdAt'])
print('Status: ', info['status'])
def is_json(data):
try:
json.loads(data)
except ValueError as e:
return False
return True
acme_config = load_acme_parameters(g_debug)
main(g_debug)
[Part 4](https://www.alibabacloud.com/blog/let%27s-encrypt-acme-with-alibaba-cloud-api-gateway-and-cdn-%E2%80%93-part-4_593786? In spm = a2c65.11461447.0.0.7a192881oOb2lp), you'll dig deeper into the ACME API to learn how to build each part of the JSON body, sign the payload, and process the results.
Recommended Posts