Over the last few weeks, I've been setting up this website on Azure. It's a simple static website, so the setup is just to upload the static assets to an Azure Storage Account and set up an Azure CDN with a custom domain name in front of it. I also wanted to use my own custom TLS certificate for it sourced from Let's Encrypt, which means I needed to set up an automatic renewal workflow for said cert. But the web server is in Azure CDN's control so I can't run something turnkey like Certbot. Besides, I wanted to do this as much as possible by myself rather than relying on third-party stuff anyway, so I implemented my own ACME renewal workflow that runs periodically in an Azure Function.
I'll write more details about the Azure setup later. For now, I want to share what I learned about the ACME v2 protocol by providing a simple explanation of how the simplest-possible client implementation works.
Introduction
The ACME v2 protocol is defined in an RFC, and also uses concepts from other RFCS:
- RFC 4648 - The Base16, Base32, and Base64 Data Encodings
- RFC 7515 - JSON Web Signature
- RFC 7517 - JSON Web Key
- RFC 7518 - JSON Web Algorithms (JWA)
- RFC 7638 - JSON Web Key (JWK) Thumbprint
- RFC 8555 - Automatic Certificate Management Environment (ACME)
You don't have to read them in their entirety before you start, but it helps to have them open for reference. I will link to the relevant sections of the RFCs where necessary.
An ACME server is the entity that provides the TLS certificate. An ACME client is the entity that creates a certificate signing request (CSR) and submits it to the ACME server for signing. The ACME server then performs some validation to verify that the client owns the domain(s) that it is requesting a certificate for, which involves multiple back-and-forths between the client and the server. Finally, the server returns a certificate to the client which the client can then start using.
The A in ACME stands for Automatic, and indeed the great thing about the protocol is that it can be completely automated. All interaction between the client and server is via the HTTP protocol.
Create an account key
All interactions with the server other than the directory request and the "new nonce" request are authenticated. The client generates an account key in one of the formats supported by the server based on section 3.1 "alg" (Algorithm) Header Parameter Values for JWS in the JSON Web Algorithm RFC with restrictions as noted in section 6.2 Request Authentication of the ACME RFC. The client uses this key to sign its requests using the JSON Web Signature RFC.
Check your ACME server provider's documentation for the keys it supports. In the case of Let's Encrypt, the strongest format it supports (as of this writing) are ECDSA P-384 keys. The rest of this document will use these keys for the examples.
Discover the server URLs
An ACME client starts off by querying the ACME server's directory URL
with an HTTP GET request. For Let's Encrypt, the production service's
directory URL is
https://acme-v02.api.letsencrypt.org/directory
. It also has
a staging service at directory URL
https://acme-staging-v02.api.letsencrypt.org/directory
. You
should use the staging service while developing your client, since it is
more lenient regarding throttling and issuing duplicate
certificates.
The server responds to the directory request with a
200 OK
response. This response is a JSON object that maps
URL types to URLs, like this:
{
"newAccount": "...",
"newNonce": "...",
"newOrder": "..."
}
- The
newAccount
value is the "new account" URL. - The
newNonce
value is the "new nonce" URL. - The
newOrder
value is the "new order" URL.
The directory response is described in section 7.1.1 Directory of the ACME RFC.
Get the initial nonce
To prevent replay attacks, all requests that contain a request body
must contain a nonce as part of the request body. The value of this
nonce comes from the Replay-Nonce
header in the previous
response from the server. Thus the client must check every response it
receives to see if it contain this header, and maintain a state that
holds this nonce so that it can use it for the next request.
It is possible for the server to return a Replay-Nonce
header in the response of the directory request itself. In case the
server does not do so, the client gets an initial nonce by sending an
HTTP HEAD request to the "new nonce" URL. The client expects the server
to respond with a 200 OK
response that definitely contains
a Replay-Nonce
header.
The POST request format
All interactions with the server other than the directory request and the "new nonce" request are HTTP POST requests, and contain a request body that is a JWS envelope around the actual payload. This is a multi-step process based on the JSON Web Signature RFC linked above.
The "encoded payload"
The client starts with the payload that it wants to send. This might be an empty payload, or it might be an object. In the latter case, the client serializes the object to a JSON string, then encodes the string using the encoding described in section 5 Base 64 Encoding with URL and Filename Safe Alphabet of RFC 4648. This is now the "encoded payload".
It is important to note that the payload can be empty, which means the "encoded payload" is the empty string. The HTTP POST request still has a request body, since this empty "encoded payload" is still wrapped in a JWS envelope as described below. Specifically, the payload is empty in what the ACME RFC calls "POST-as-GET" requests, which refers to requests that get the current status of an object like a REST GET request would, but are nevertheless sent as POST requests with a JWS body. See section 6.3 GET and POST-as-GET Requests of the ACME RFC for more details.
Note that the URL-safe base64 encoding is the only kind of encoding used in the entire client workflow. Future references to base64 encoding in this document will refer to this same URL-safe encoding.
The "protected header"
Next, the client constructs the "protected header" for the request. The client first creates the protected header object, which looks like this:
{
"alg": "ES384",
"nonce": "...",
"url": "...",
"jwk": {
"crv": "P-384",
"kty": "EC",
"x": "...",
"y": "..."
}
}
or like this:
{
"alg": "ES384",
"nonce": "...",
"url": "...",
"kid": "..."
}
The difference between the two formats is in the choice of the fourth
parameter, either jwk
or kid
. The choice of
parameter depends on whether the client knows the account URL or
not.
The
alg
value is the identifier of the algorithm the client used to create the account key.ES384
represents an ECDSA P-384 key. See section 3.1 "alg" (Algorithm) Header Parameter Values for JWS in the JSON Web Algorithm RFC for the list of values corresponding to other key types.The
nonce
value is the nonce string from the previous response, as explained previously.The
url
value is the URL of the current request, ie the "new account" URL.If the client does not know the account URL, it must set the
jwk
parameter to a value that describes the account key. The example is for the ECDSA P-384 key format. For other formats, thekty
parameter identifies the format using the identifiers listed in section 6.1 of the JSON Web Algorithm RFC. The other parameters vary depending on the key type, and are documented in the other subsections of section 6 in the same RFC.If the client does know the account URL, it must set the
kid
parameter to the account URL.
The client then serializes this protected header object to JSON and base64-encodes it. The result is the "protected header".
The "signature"
Lastly, the client needs to construct the "signature" of the request.
It takes the "protected header", appends an ASCII
. (U+002E)
, then appends the "encoded payload". This
resulting string is then converted to bytes in the ASCII encoding, and
these ASCII-encoded bytes are the signature input. The client then uses
the key to sign this signature input and get the signature bytes. The
signature bytes are base64-encoded into a string, which becomes the
"signature" of the request.
The signing algorithm depends on the key type. For ECDSA P-384 keys the algorithm is SHA-384. For other formats, see section 3.1 "alg" (Algorithm) Header Parameter Values for JWS in the JSON Web Algorithm RFC.
The client now constructs the HTTP request body. It is a JSON object that looks like this:
{
"payload": "...",
"protected": "...",
"signature": "..."
}
The
payload
value is the "encoded payload".The
protected
value is the "protected header".The
signature
value is the "signature".
The client also sets the
Content-Type: application/jose+json
header on the
request.
Create an account
The client constructs a new account payload that looks like this:
{
"contact": [
"..."
],
"termsOfServiceAgreed": true
}
The contact
value is an array of strings, each one
representing a contact URL for the account owner. The simplest choice is
to have a single mailto
URL for the webmaster of the domain
that you're planning to get the certificate for, such as
"mailto:webmaster@example.com"
.
The termsOfServiceAgreed
value is a boolean representing
whether the client agrees to the terms of service of the server. The URL
to the terms of service is returned in the initial directory response,
and the protocol expects that the client will involve some human
interaction to fetch and agree to them.
The client sends an HTTP POST request to the "new account" URL with
this payload in a JWS envelope. Since it does not have the account URL
at this point, it uses the first format of the protected header that
contains the jwk
key.
The client expects the server to return a 201 Created
or
200 OK
response. The former implies that the server has
created a new account corresponding to the account key, while the latter
indicates the server has already seen this account key before and so
will use the existing account.
The body of the response contains a JSON object representing the account, like this:
{
"status": "..."
}
- The
status
value is the state of the account, and must be the value"valid"
to be able to proceed.
The response is described in full detail in section 7.1.2 Account Objects of the ACME RFC.
The response also contains a Location
header that
contains the "account URL". This account URL uniquely identifies the
account, and is used in all future requests as the kid
value of the "protected header". Thus the client must save it in its
state.
As mentioned above, the server returns a 200 OK
response
if it already has an existing account corresponding to the account key.
In this way, the "new account" URL also functions as a "get existing
account" URL. Thus if the client wants to reuse the same account key
multiple times, it can use the "new account" URL to "discover" the
account URL of the existing account corresponding to that account key.
The server allows the client to omit the
"termsOfServiceAgreed"
key if the account already exists,
unless the terms of service have changed since the last time the client
set "termsOfServiceAgreed"
to true
for this
account.
Alternatively, if the client expects the account to have already been
created previously, it can persist the account key and the
account URL, and skip posting to the "new account" URL entirely. However
it should probably still POST-as-GET the account URL itself, just to
make sure the account is still in the "valid"
state.
Create an order
The next step is to place an "order" for the certificate that the client wants. To do this, the client constructs the new order request payload, which looks like this:
{
"identifiers": [
{ "type": "dns", "value": "..." }
]
}
Each value of the identifiers
array represents an
identifier that must be validated. The value
of each
identifier is the domain name that the client wants to request a
certificate for.
The client sends an HTTP POST request to the "new order" URL with
this payload in a JWS envelope. As mentioned above, since the client now
knows the account URL, it uses the second format of the protected header
that contains the kid
key.
The client expects the server to return a 201 Created
response. If the order for these identifiers already existed and that
order is still valid, then the server returns the existing order. The
response status code is still 201 Created
in this case.
The response contains a Location
header that contains
the "order URL". The client should save this URL in its order state.
The body of this response contains a JSON object representing the order, like this:
{
"status": "..."
}
- The
status
value represents the state of the order, and is used to determine how to proceed.
The response is described in full detail in section 7.1.3 Order Objects of the ACME RFC.
If the order is in the
"pending"
state, the server is waiting for the client to complete the authorizations of the order. The client handles the order as described in the The order is"pending"
section below.If the order is in the
"ready"
state, the authorizations of the order have already been completed and the order is waiting to be finalized. The client handles the order as described in the The order is"ready"
section below.If the order is in the
"processing"
state, the order has already been finalized, and is being processed by the server. The client should continue to poll the order using the order URL until it has moved to another state.If the order is in the
"valid"
state, the order has already been completed. The client handles the order as described in the The order is"valid"
section below.If the order is in the
"invalid"
state, the server has rejected the order. The client should abort the order workflow.
Creating an order is described in section 7.4 Applying for Certificate Issuance of the ACME RFC. The change of state of an order object is described in section 7.1.6 Status Changes of the ACME RFC.
The order
is "pending"
If the order is in the "pending"
state, the server is
waiting for the client to complete the authorizations of the order. The
order object looks like this:
{
"authorizations": [
"...",
...
],
"status": "pending"
}
- The
authorizations
value is an array of strings, each of which represents an "authorization URL" for this order. There will be one such URL for each identifier in the order request.
The server is waiting for the client to complete the authorizations of the order. As mentioned previously, there will be one authorization URL for each identifier the client sent in the initial order request. Each authorization contains a set of challenges that prove that the client has ownership of the corresponding domain.
The client completes every authorization before proceeding with the order.
Completing an authorization
The client fetches the authorization object by performing a
POST-as-GET request to the authorization URL, and expects the server to
return a 200 OK
response. The response contains a JSON
object that represents the current state of the authorization, like
this:
{
"challenges": [
{ "type": "...", "token": "...", "url": "...", "status": "..." },
...
],
"status": "..."
}
The
challenges
value of the authorization object is an array of challenge objects.The
type
value of a challenge object is the type of the challenge. For example, an http-01 challenge has the value"http-01"
.The
token
value is the token of the challenge. This has different uses depending on the type of the challenge.The
url
value is the URL of the challenge. The client uses this URL to indicate to the server that it has satisfied the requirements of the challenge, so the server should begin its verification. The client also polls this URL to get the updated state of the challenge.The response returned from posting to the challenge URL is a JSON object that is identical to this challenge object in the
challenges
array.The
status
value is the state of the challenge.
The value of the
status
key of the object in the response body represents the state of the order, and is used to determine how to proceed.
The authorization object is described in full detail in section 7.1.4 Authorization Objects of the ACME RFC.
If the authorization is in the
"pending"
state, it is waiting for the client to fulfill at least one challenge of the order.If the authorization is in the
"valid"
state, it has already been completed and succeeded, and there is nothing more to do for this authorization.If the authorization is in the
"invalid"
state, it has already been completed and failed. The parent order of this authorization would've been marked as"invalid"
as well, so the client should abort the order workflow.
To complete a pending authorization, the client chooses one of its challenges and tries to fulfill it.
If the challenge is in the
"pending"
state, it is waiting for the client to satisfy the requirements of the challenge depending on its type. Once the client has done so, it sends an HTTP POST request to the challenge URL with an empty JSON object as the payload (note: not an empty payload, but an empty object{}
as the payload) and expects a200 OK
response. It then polls the challenge URL to get its updated status with an empty payload (note: not an empty object, but an empty payload, just like a regular POST-as-GET request).See Extra: Fulfilling an http-01 challenge for how to fulfill an http-01 challenge.
See Extra: Fulfilling a dns-01 challenge for how to fulfill a dns-01 challenge.
If the challenge is in the
"processing"
state, the client has previously posted to the challenge URL. The server is still verifying the challenge, so the client should continue to poll the challenge URL.If the challenge is in the
"valid"
state, the client has previously posted to the challenge URL and the server has verified the challenge successfully. There is nothing more for the client to do with this challenge.If the challenge is in the
"invalid"
state, the client has previously posted to the challenge URL and the server has rejected the challenge. The parent authorization of this challenge, and thus the parent order of that authorization, would've been marked as"invalid"
as well, so the client should abort the order workflow.
Note that it is possible for the challenge to remain in the
"pending"
state for a short period of time after the client
has posted to the challenge URL, rather than immediately moving to
"processing"
state. If the client knows that it has already
posted to the challenge URL, it should treat this just like if the
challenge was in the "processing"
state and continue
polling the challenge URL, waiting for it to change state. (This
behavior appears to violate the RFC, but is displayed by Let's
Encrypt.)
The change of state of authorization and challenge objects is described in section 7.1.6 Status Changes of the ACME RFC.
Once the client observes that the challenge is in the
"valid"
state, it polls the authorization at the
authorization URL till the authorization too reaches the
"valid"
state, as described above.
Once every authorization in the order is "valid"
, the
client polls the order, waiting for it to reach the "ready"
state, as described above.
The order is
"ready"
If the order is in the "ready"
state, the authorizations
of the order have already been completed and the order is waiting to be
finalized. The order object looks like this:
{
"finalize": "...",
"status": "ready"
}
- The
finalize
value is a string representing the "finalize URL" of this order.
The client constructs a DER-encoded certificate signing request
(CSR). Depending on the ACME server provider, there may be various
restrictions on the key type, key size, and properties of the CSR. For
example, Let's Encrypt enforces that the account key is not reused as
the CSR private key, and that the CSR does not contain
Not Before
and Not After
properties since
Let's Encrypt sets these itself.
The client stores the private key of the CSR in its state. It then sends an HTTP POST request to the order's "finalize URL" with a payload that looks like this:
{
"csr": "..."
}
- The
csr
value is the base64-encoded CSR.
The client expects an 200 OK
response from the server,
with a response containing the updated order object. It then polls the
order URL until the order has reached the "valid"
state.
The order is
"valid"
If the order is in the "valid"
state, the order has been
completed. The order object looks like this:
{
"certificate": "...",
"status": "valid"
}
- The
certificate
value is a string representing the URL of the signed certificate.
The client downloads the certificate from this URL using a POST-as-GET request. It combines this certificate with the private key of the CSR it had generated previously, and begins using it for its webserver. This is the end of the order workflow.
Summary
I hope this serves as a useful starting point for anyone wanting to implement an ACME v2 client from scratch. Of course, there are far more details in the ACME RFC that I haven't covered here, such as revoking certificates, account management and error response formats. Check the RFC if something doesn't work the way I describe it here.
Extra: Fulfilling an http-01 challenge
To fulfill an http-01 challenge, the client instructs the webserver
of the domain to respond to a certain URL with certain content. The URL
and content are derived from the challenge properties and are thus
unique to that particular challenge. To verify the challenge, the ACME
server fetches this URL (using the http
scheme) and
verifies that it has the content it expected. Being able to instruct the
webserver in this way counts as proof that the client owns the
domain.
First, the client computes its "JWK thumbprint". It does this by
taking the same JWK object that it used as the jwk
value in
the "new account" request, then serializing it to canonical JSON. Then
it gets the UTF-8 bytes of the JSON string, hashes the bytes with
SHA-256, and base64-encodes the hash bytes. The resulting string is the
"JWK thumbprint".
Note that it is important to serialize the JWK object using canonical JSON (no whitespace, keys in lexical order) to ensure that both the client and server serialize the key to the same result.
Also note that it is required to use SHA-256 for the hash operation, not the key-format-dependent hash operation used to sign JWS payloads. See RFC 7638 for more details.
The client then takes the challenge token, appends an ASCII
. (U+002E)
, then appends the "JWK thumbprint". This
resulting string encoded to UTF-8 bytes becomes the content of the
challenge response. The URL of the challenge response is
/.well-known/acme-challenge/$(challenge.token)
http-01 challenges are described in section 8.3 HTTP Challenge of the ACME RFC.
Extra: Fulfilling a dns-01 challenge
To fulfill a dns-01 challenge, the client instructs the DNS server
that responds for the domain to serve a specific TXT record with certain
content. The name of the TXT record is
_acme-challenge.$domain
. Its text content is derived from
the challenge properties and is thus unique to that particular
challenge. To verify the challenge, the ACME server queries the TXT
record and verifies that it has the content it expected. Being able to
instruct the DNS server in this way counts as proof that the client owns
the domain.
Similar to the http-01 challenge process described above, the client
constructs a string by taking the challenge token and appending an ASCII
. (U+002E)
and the "JWK thumbprint" to it. This resulting
string becomes the contents of the TXT record.
dns-01 challenges are described in section 8.4 DNS Challenge of the ACME RFC.
Unlike an http-01 challenge, a dns-01 challenge can be used for
arbitrary non-HTTP endpoints that need to serve TLS. dns-01 challenges
are also the only kind of challenge that Let's Encrypt accepts when
requesting certs for a wildcard domain. (For a wildcard domain order
like *.example.org
, the TXT record that the server will
resolve is _acme-challenge.example.org
You will of course need a programmable DNS server so that the ACME
client can dynamically modify the _acme-challenge.$domain
DNS record. If your domain's DNS server does not allow such programmatic
access, you can set up a separate programmable DNS server just for the
_acme-challenge.$domain
record, and manually configure an
NS record for _acme-challenge.$domain
in your domain's DNS
server to point to your programmable one.