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

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&#39;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:

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&#39;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:

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:

Features

Requirements

Testing

The test suite includes an ACME test server and challenge responder:

  1. Start the test environment:

    make test-env
  2. Run 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

License

Licensed under the Apache License, Version 2.0. See LICENSE file for details.

Credits