Skip to content

Latest commit

 

History

History
236 lines (199 loc) · 8.42 KB

File metadata and controls

236 lines (199 loc) · 8.42 KB

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.

Implementation reference: https://github.com/emqx/emqx/tree/master/plugins/emqx_acme Doc reference: https://docs.emqx.com/en/emqx/v6.1/extensions/plugin-catalog/6.1/emqx-acme.html

Major Changes from Upstream

  • Reimplemented using gen_statem for better state management
  • Added tests
  • Removed YAML dependency in favor of direct JSON field validation
  • Removed base64url dependency in favor of base64 with urlsafe mode and padding => 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.pem for 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 (binary or string)
    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 authorization
  • token: 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.sh handles 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_SCRIPT environment 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:

  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

  • 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