Erlang ACME Client (RFC8555)
An Erlang implementation of the Automatic Certificate Management Environment (ACME) protocol as specified in RFC8555.
This is a fork of processone/p1_acme with significant refactoring. Special thanks to ProcessOne for the original implementation.
Major Changes from Upstream
-
Reimplemented using
gen_statemfor better state management - Added tests
- Removed YAML dependency in favor of direct JSON field validation
-
Removed
base64urldependency in favor ofbase64withurlsafemode andpadding => false - Erlang with OTP >= 27 support (so far no support for OTP < 27)
- Rebar3-only build system
- Added polling for challenge status for each domain
-
Support
file:///path/to/file.pemfor account key and CA certificates - Always generate certificate private key, do not allow to provide it
- Support encrypted account key
Usage
HTTP-01 Challenge
%% Start the application
application:ensure_all_started(acme_client).
%% Prepare the HTTP-01 challenge responder function
ChallengeFun = fun(Challenges) ->
%% Set up HTTP-01 challenge response
%% The ACME server will make a GET request to:
%% http://{Domain}/.well-known/acme-challenge/{Token}
%% Expected response is the Key
lists:foreach(
fun(#{domain := Domain, token := Token, key := Key}) ->
%% Note: The domain is a binary string without idna encoding
ok = my_http_server:add_challenge(Domain, Token, Key)
end,
Challenges
)
end.
%% Request configuration
Request = #{
%% ACME directory URL (e.g., Let's Encrypt staging/production)
dir_url => "https://acme-staging-v02.api.letsencrypt.org/directory",
%% Domains to get certificate for
%% Note: Not all ACME servers support wildcard certificates
domains => [<<"example.com">>, <<"*.example.com">>],
%% Optional contact information
contact => ["mailto:admin@example.com"],
%% Certificate key type (ec | rsa)
cert_type => ec,
%% Challenge type: "http-01" or "dns-01"
challenge_type => <<"http-01">>,
%% Challenge responder function
challenge_fn => ChallengeFun,
%% Optional trusted CA certificates for issued certificate-chain validation
ca_certs => [CACert],
%% Optional existing account key (will generate new one if not provided)
%% Note: The account key is used to identify the account at the ACME server
%% It's a good practice to use the same account key for certificate renewal and revocation
acc_key => AccountKey,
%% Optional account key password
acc_key_pass => undefined, % | fun() -> AccountKeyPassword end,
%% Optional HTTP client options
httpc_opts => #{
ssl => [{verify, verify_none}],
ipfamily => inet % default is inet6fb4
},
%% Optional output directory for certificate files
%% When provided, the keys and certificates will be saved to the directory
%% and the returned map will contain only the file names
%% for example:
%% #{ acc_key => "/path/to/output/acme-client-account-key.pem",
%% cert_key => "/path/to/output/key.pem",
%% cert_chain => "/path/to/output/cert.pem"
%% }
output_dir => "/path/to/output"
}.
%% Request the certificate (timeout in milliseconds)
case acme_client:run(Request, 60000) of
{ok, #{
acc_key := AccKey, %% Account private key or PEM file path
cert_key := CertKey, %% Certificate private key or PEM file path
cert_chain := [Cert|_] %% Certificate chain or PEM file path
}} ->
%% Success! Use the certificate
ok;
{error, Reason} ->
%% Handle error
error
end.The client implements a state machine that handles:
- Directory discovery
- Account registration/verification
- Order creation
- Domain authorization
- Challenge setup and verification
- Certificate issuance
- Automatic retries for temporary failures
- Proper nonce management
DNS-01 Challenge
DNS-01 challenges are required for wildcard certificates and are useful when HTTP-01 is not available.
%% DNS-01 challenge responder function
DnsChallengeFun = fun(Challenges) ->
%% Set up DNS TXT records for DNS-01 challenge
%% The ACME server will query:
%% _acme-challenge.{Domain} TXT {RecordValue}
lists:foreach(
fun(#{domain := Domain, record_name := RecordName, record_value := RecordValue}) ->
%% Create DNS TXT record using your DNS provider's API
%% Example: AWS Route53, Cloudflare, Google Cloud DNS, etc.
ok = my_dns_provider:add_txt_record(RecordName, RecordValue)
end,
Challenges
)
end.
Request = #{
dir_url => "https://acme-staging-v02.api.letsencrypt.org/directory",
domains => [<<"example.com">>, <<"*.example.com">>], % Wildcard requires DNS-01
challenge_type => <<"dns-01">>,
challenge_fn => DnsChallengeFun,
%% ... other options
}.Note: For DNS-01 challenges, the challenge_fn callback receives:
domain: The domain name (e.g.,<<"example.com">>)record_name: The TXT record name (e.g.,<<"_acme-challenge.example.com">>)record_value: The base64url-encoded SHA-256 digest of the key authorizationtoken: The challenge token (for reference)
You can use open_port to execute command-line tools (AWS CLI, Cloudflare API, etc.) or integrate directly with your DNS provider's API.
Example: AWS Route53 using AWS CLI:
%% DNS-01 challenge responder using AWS Route53 CLI script
%% See examples/aws_route53_dns_challenge.sh for the bash script implementation
Route53ChallengeFun = fun(Challenges) ->
ScriptPath = os:getenv("AWS_ROUTE53_SCRIPT", "examples/aws_route53_dns_challenge.sh"),
lists:foreach(
fun(#{record_name := RecordName, record_value := RecordValue}) ->
%% Execute bash script via open_port
Cmd = io_lib:format(
"~s ~s ~s",
[ScriptPath, RecordName, RecordValue]
),
Port = open_port({spawn, lists:flatten(Cmd)}, [exit_status, stderr_to_stdout]),
receive
{Port, {exit_status, 0}} ->
ok;
{Port, {exit_status, Status}} ->
error({aws_script_failed, Status});
{Port, {data, Data}} ->
%% Log script output
io:format("AWS script output: ~s~n", [Data]),
receive
{Port, {exit_status, 0}} -> ok;
{Port, {exit_status, Status}} -> error({aws_script_failed, Status})
end
after 30000 ->
erlang:port_close(Port),
error(timeout)
end
end,
Challenges
)
end.Note:
-
The bash script
examples/aws_route53_dns_challenge.shhandles all AWS Route53 logic -
Make sure AWS CLI is installed and configured with appropriate credentials (
AWS_ACCESS_KEY_ID,AWS_SECRET_ACCESS_KEY, or~/.aws/credentials) -
You can optionally set
AWS_ROUTE53_SCRIPTenvironment variable to specify a custom script path -
Optionally provide hosted zone ID as third argument to avoid lookup:
ScriptPath RecordName RecordValue ZoneID
Features
- Full ACME protocol implementation (RFC8555)
- HTTP-01 challenge support
- DNS-01 challenge support (required for wildcard certificates)
- Automatic account registration
- Certificate issuance and renewal
- Robust error handling and retries
Requirements
- Erlang/OTP >= 27
- Rebar3
Testing
The test suite includes an ACME test server and challenge responder:
Start the test environment:
make test-envRun the test suite:
make ct
The ACME challenge responder runs in the container acme-challenge-responder.
For implementation details, see test/acme_client_challenge_responder.erl.
Roadmap
- Implement certificate revocation
-
Implement account reuse with
onlyReturnExisting - Add DNS challenge support
- Add support for private key password
License
Licensed under the Apache License, Version 2.0. See LICENSE file for details.
Credits
- Original implementation by ProcessOne
- ACME protocol specification by IETF