A from-scratch implementation of an ACME (Automatic Certificate Management Environment) client, written in Python, capable of autonomously obtaining, serving, and revoking X.509 certificates from an ACME-compliant CA. Built as the final project for the Network Security course at ETH Zurich.
The client implements the full RFC 8555 ACME protocol flow, including both http-01 and dns-01 challenge types, a custom authoritative DNS server, and a post-issuance HTTPS server that serves the obtained certificate.
- Overview
- Architecture
- Project Structure
- Protocol Flow
- Challenge Types
- Components
- Installation
- Usage
- Testing with Pebble
- Dependencies
- License
The ACME protocol (RFC 8555) defines a standard interface through which a certificate authority can autonomously validate domain ownership and issue TLS certificates. This project implements a full ACME client from scratch, with no reliance on existing ACME libraries such as acme or certbot.
Key capabilities:
- Full ACME protocol state machine: directory bootstrap, account creation, order placement, challenge resolution, CSR submission, certificate download, and revocation.
- Support for both
http-01(well-known HTTP token) anddns-01(DNS TXT record) challenge types. - Custom UDP DNS server that serves both
AandTXTrecords, enabling self-containeddns-01challenge resolution without external DNS infrastructure. - JOSE (JSON Object Signing and Encryption) implementation including JWK construction, RS256 signing, and key thumbprint computation per RFC 7638.
- Post-issuance HTTPS server that wraps the newly obtained certificate and serves it immediately.
- Graceful shutdown via a dedicated HTTP control endpoint.
- Tested against Pebble, the Let's Encrypt lightweight ACME testing CA.
The application is composed of five concurrent network services and a central ACME client, all orchestrated from a single entry point via Python threads:
ββββββββββββββββββββββββββββββββββββββββββββ
β main.py β
β (FSM orchestrator + thread coordinator) β
ββββββββββββββ¬ββββββββββββββββββββββββββββββ
β spawns
ββββββββββββββββββββββββββββΌββββββββββββββββββββββββββββββββ
β β β
ββββββββΌβββββββ ββββββββββΌβββββββββ ββββββββββΌβββββββββ
β DNS Server β β HTTP Challenge β β HTTP Shutdown β
β :10053/UDP β β Server :5002 β β Server :5003 β
βββββββββββββββ βββββββββββββββββββ βββββββββββββββββββ
β² β² β
β TXT record injection β token/key-auth injection β /shutdown
β β βΌ
ββββββββ΄βββββββββββββββββββββββββββ΄βββββββββββββββββββββββββββββββββββββββ
β ACMEClient β
β getInitialNonce β createNewAccount β requestNewCertificate β β
β solveChallenges β finalizeRequest β downloadCertificate β
ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
β after certificate obtained
βΌ
βββββββββββββββββββββββ
β HTTPS Certificate β
β Server :5001 β
βββββββββββββββββββββββ
The ACME client drives a finite state machine. On a nonce rejection or transient validation failure the FSM restarts from the nonce acquisition step, ensuring resilience without manual intervention.
ACME-Client/
βββ main.py # Entry point and FSM orchestrator
βββ networking/
β βββ ACMEClient.py # Core ACME protocol implementation
β βββ dnsServer.py # Custom authoritative DNS server
β βββ httpWebServer.py # HTTP server for http-01 challenge responses
β βββ httpsWebServer.py # HTTPS server serving the obtained certificate
β βββ httpShutdownServer.py # HTTP control server for graceful shutdown
β βββ constructBasicHTTPResponse.py
β βββ ValidationError.py
βββ challenge_solver/
β βββ challengeSolver.py # Challenge type dispatcher
β βββ dnsResponder.py # dns-01 challenge responder (TXT record injection)
β βββ httpResponder.py # http-01 challenge responder (token injection)
βββ utilities/
β βββ signing.py # RSA key generation, JWK, JOSE signing, CSR construction
β βββ encoding.py # URL-safe base64 encoding utilities
β βββ parseArguments.py # CLI argument parser
βββ scripts/
β βββ launcher.py # Remote launcher (reads config from HTTP endpoint)
β βββ docker-compile.sh # Docker-based dependency installation
β βββ docker-run.sh # Docker-based execution wrapper
βββ project/
βββ pebble.minica.pem # Pebble CA root certificate for TLS verification
βββ compile # Dependency installation script
βββ run # Shell entrypoint wrapper
The ACME protocol interaction follows RFC 8555 and is implemented as a sequential state machine inside main.py:
-
Directory bootstrap β On construction,
ACMEClientfetches the ACME directory URL to resolve thenewNonce,newAccount,newOrder, andrevokeCertendpoints. -
Nonce acquisition (
getInitialNonce) β AHEADrequest tonewNonceretrieves the firstReplay-Nonce. Every subsequent nonce is extracted from the response header of the preceding request. -
Account creation (
createNewAccount) β A JOSE-signedPOSTtonewAccountregisters a new account. A fresh RSA-2048 key pair is generated per session. The account URL returned in theLocationheader is stored and used as thekidin all subsequent protected headers. -
Order placement (
requestNewCertificate) β APOSTtonewOrderwith a list of DNS identifier objects initiates a certificate order. The response provides thefinalizeURL and a list ofauthorizationsURLs, one per identifier. -
Challenge resolution (
solveChallenges) β For each authorization URL, the client performs a GET-as-POST to retrieve the challenge object, then calls the appropriate responder (dnsResponderorhttpResponder) to provision the proof. It then notifies the ACME server that the challenge is ready and polls the authorization URL until its status transitions tovalid. -
Finalization (
finalizeRequest) β A CSR is constructed with all requested identifiers as Subject Alternative Names and submitted to thefinalizeURL. The private key associated with the CSR is retained in memory. -
Certificate download (
downloadCertificate) β The client performs a GET-as-POST against the account URL to retrieve the orders list, queries the latest order for thecertificateURL, and downloads the PEM certificate chain. -
Certificate serving β The certificate and private key are written to
cert.pemandkey.pemrespectively. The HTTPS server is started with these files and serves on port5001. -
Revocation (optional,
--revoke) β If requested, the PEM certificate is read back, converted to DER, base64url-encoded, and submitted torevokeCertin a JOSE-signed payload.
The http-01 challenge proves domain control by serving a key authorization token at a well-known HTTP path:
GET http://<domain>:5002/.well-known/acme-challenge/<token>
The httpResponder injects the token and the computed key_authorization (<token>.<thumbprint>) as class-level attributes on HttpChallengeServer. The server, already running in a background thread, picks these up and serves the correct response to the ACME server's validation request.
The dns-01 challenge proves domain control by serving a DNS TXT record:
_acme-challenge.<identifier> TXT <digest>
where <digest> is the URL-safe base64 encoding of SHA-256(<token>.<thumbprint>).
The dnsResponder computes this value and injects a new TXT entry directly into the DNSResolver.delegate.entries dictionary of the running DNS server instance. The DNS server, already listening on UDP port 10053, will serve the newly injected record immediately upon the next query from the ACME server.
networking/ACMEClient.py
The central class driving all ACME interactions. Manages state across the protocol lifecycle: nonce, account location, authorization URLs, finalize URL, private key bytes, and certificate bytes. Constructs JOSE-protected requests by encoding and signing headers and payloads with RS256 using the Signing utility.
Notable design detail: the protected header switches between a jwk field (before account creation) and a kid field (after), as required by RFC 8555 Β§6.2.
networking/dnsServer.py
A lightweight authoritative DNS server built on top of dnslib. Binds to 0.0.0.0:10053 over UDP. Handles A and TXT query types. Record names support wildcard patterns, which are converted to regex at query resolution time. The DNSResolver instance is exposed via a class-level delegate reference, allowing challenge responders to inject records at runtime without restarting the server.
networking/httpWebServer.py
A minimal http.server.HTTPServer subclass running on port 5002. Handles two resources: the root path (aliveness) and the ACME challenge path /.well-known/acme-challenge/<token>. The token and key authorization string are stored as class-level attributes, set by httpResponder prior to challenge notification.
networking/httpsWebServer.py
An HTTPServer instance whose socket is wrapped with ssl.wrap_socket using the freshly issued certificate and private key. Runs on port 5001 in a background thread after the certificate has been obtained. Serves a basic root response to confirm the certificate is live.
networking/httpShutdownServer.py
An HTTP server on port 5003 that accepts a GET /shutdown request and triggers a graceful teardown of the HTTP challenge server and HTTPS server before calling exit(0). Holds references to both servers as class-level attributes.
utilities/signing.py
Handles all cryptographic operations:
- RSA-2048 key pair generation using
cryptography.hazmat.primitives.asymmetric.rsa. - JWK object construction from the public exponent and modulus, both base64url-encoded per RFC 7517.
- PKCS1v15 / SHA-256 signature computation over the
<encoded_header>.<encoded_payload>string, as required by JOSE. - JWK thumbprint computation per RFC 7638: canonical JSON serialization of the JWK followed by SHA-256 hashing.
- CSR construction using
x509.CertificateSigningRequestBuilderwith Subject Alternative Names for all requested domains. - DER certificate loading for revocation.
utilities/encoding.py
Provides the URL-safe, padding-stripped base64 encoding required throughout the JOSE and ACME specifications, along with URL manipulation helpers (removePortFromUrl, insertPortInUrl) needed to reconcile the port discrepancy between JOSE url header values and actual request URLs when running against Pebble.
Requirements: Python 3.10+
Install dependencies:
pip3 install cryptography dnslib requestsOr use the provided script:
bash project/compilepython3 main.py <challenge_type> --dir <dir_url> --record <ip> --domain <domain> [--domain <domain> ...] [--revoke]
| Argument | Required | Description |
|---|---|---|
challenge_type |
Yes | Challenge type to use: http01 or dns01 |
--dir |
Yes | URL of the ACME server directory endpoint |
--record |
Yes | IPv4 address the DNS server will return for all registered A record queries |
--domain |
Yes | Domain name(s) to include in the certificate (repeatable) |
--revoke |
No | If set, revoke the certificate after issuance |
Obtain a certificate for a single domain using http-01, against a local Pebble instance:
python3 main.py http01 \
--dir https://localhost:14000/dir \
--record 127.0.0.1 \
--domain example.comObtain a wildcard-compatible certificate for multiple domains using dns-01:
python3 main.py dns01 \
--dir https://localhost:14000/dir \
--record 127.0.0.1 \
--domain example.com \
--domain www.example.comObtain and immediately revoke a certificate:
python3 main.py http01 \
--dir https://localhost:14000/dir \
--record 127.0.0.1 \
--domain example.com \
--revokeOn successful completion:
key.pemβ RSA private key for the certificate (PEM)cert.pemβ Issued certificate chain (PEM)- HTTPS server live on port
5001 - Shutdown endpoint available at
http://localhost:5003/shutdown
Pebble is a minimal ACME server intended for integration testing. The project/pebble.minica.pem file contains Pebble's root CA certificate and is passed to all requests calls via the verify= parameter to enable TLS verification against Pebble's self-signed certificate.
Start Pebble with the appropriate configuration (DNS resolver pointed to 0.0.0.0:10053 for dns-01 testing), then run the client as shown in the examples above.
| Package | Purpose |
|---|---|
cryptography |
RSA key generation, JOSE signing, CSR construction, x509 |
dnslib |
DNS server and record type primitives |
requests |
HTTP/HTTPS communication with the ACME server |
All are standard, well-maintained packages available on PyPI.
See LICENSE.