Welcome to mirror list, hosted at ThFree Co, Russian Federation.

github.com/processone/ejabberd.git - Unnamed repository; edit this file 'description' to name the repository.
summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorEvgeniy Khramtsov <ekhramtsov@process-one.net>2017-11-15 10:01:30 +0300
committerEvgeniy Khramtsov <ekhramtsov@process-one.net>2017-11-15 10:01:30 +0300
commitb04c6b7d7513025006847a4da1bdca34dc35f87f (patch)
tree35a43055ac10a4ddb2757d846deb18d022699b8a
parentaf49472373bf31d4cf5a04847e882f1c82e6eaac (diff)
parentce99db05954a3170c4c5a5a45b18ea391fd86987 (diff)
Merge branch 'lets_encrypt_acme_support' of git://github.com/angelhof/ejabberd into angelhof-lets_encrypt_acme_support
Conflicts: rebar.config src/ejabberd_pkix.erl
-rw-r--r--ejabberd.yml.example32
-rw-r--r--include/ejabberd_acme.hrl53
-rw-r--r--rebar.config1
-rw-r--r--src/acme_challenge.erl145
-rw-r--r--src/ejabberd_acme.erl1166
-rw-r--r--src/ejabberd_acme_comm.erl393
-rw-r--r--src/ejabberd_admin.erl85
-rw-r--r--src/ejabberd_http.erl4
-rw-r--r--src/ejabberd_pkix.erl9
9 files changed, 1874 insertions, 14 deletions
diff --git a/ejabberd.yml.example b/ejabberd.yml.example
index ffc6a26c7..05501400b 100644
--- a/ejabberd.yml.example
+++ b/ejabberd.yml.example
@@ -655,6 +655,38 @@ language: "en"
##
## captcha_limit: 5
+###. ====
+###' ACME
+##
+## In order to use the acme certificate acquiring through "Let's Encrypt"
+## an http listener has to be configured to listen to port 80 so that
+## the authorization challenges posed by "Let's Encrypt" can be solved.
+##
+## A simple way of doing this would be to add the following in the listen
+## configuration field:
+## -
+## port: 80
+## ip: "::"
+## module: ejabberd_http
+
+acme:
+
+ ## A contact mail that the ACME Certificate Authority can contact in case of
+ ## an authorization issue, such as a server-initiated certificate revocation.
+ ## It is not mandatory to provide an email address but it is highly suggested.
+ contact: "mailto:example-admin@example.com"
+
+
+ ## The ACME Certificate Authority URL.
+ ## This could either be:
+ ## - https://acme-v01.api.letsencrypt.org - (Default) for the production CA
+ ## - https://acme-staging.api.letsencrypt.org - for the staging CA
+ ## - http://localhost:4000 - for a local version of the CA
+ ca_url: "https://acme-v01.api.letsencrypt.org"
+
+## The directory in which certificates will be saved
+cert_dir: "/usr/local/var/lib/ejabberd/"
+
###. =======
###' MODULES
diff --git a/include/ejabberd_acme.hrl b/include/ejabberd_acme.hrl
new file mode 100644
index 000000000..f48a6d8b9
--- /dev/null
+++ b/include/ejabberd_acme.hrl
@@ -0,0 +1,53 @@
+
+-record(challenge, {
+ type = <<"http-01">> :: bitstring(),
+ status = pending :: pending | valid | invalid,
+ uri = "" :: url(),
+ token = <<"">> :: bitstring()
+ }).
+
+-record(data_acc, {
+ id :: list(),
+ ca_url :: url(),
+ key :: jose_jwk:key()
+ }).
+-type data_acc() :: #data_acc{}.
+
+-record(data_cert, {
+ domain :: bitstring(),
+ pem :: pem(),
+ path :: string()
+ }).
+-type data_cert() :: #data_cert{}.
+
+%%
+%% Types
+%%
+
+%% Acme configuration
+-type acme_config() :: [{ca_url, url()} | {contact, bitstring()}].
+
+%% The main data type that ejabberd_acme keeps
+-type acme_data() :: proplist().
+
+%% The list of certificates kept in data
+-type data_certs() :: proplist(bitstring(), data_cert()).
+
+%% The certificate saved in pem format
+-type pem() :: bitstring().
+
+-type nonce() :: string().
+-type url() :: string().
+-type proplist() :: [{_, _}].
+-type proplist(X,Y) :: [{X,Y}].
+-type dirs() :: #{string() => url()}.
+-type jws() :: map().
+-type handle_resp_fun() :: fun(({ok, proplist(), proplist()}) -> {ok, _, nonce()}).
+
+-type acme_challenge() :: #challenge{}.
+
+%% Options
+-type account_opt() :: string().
+-type verbose_opt() :: string().
+-type domains_opt() :: string().
+
diff --git a/rebar.config b/rebar.config
index 323605f2a..359225843 100644
--- a/rebar.config
+++ b/rebar.config
@@ -30,6 +30,7 @@
{jiffy, ".*", {git, "https://github.com/davisp/jiffy", {tag, "0.14.8"}}},
{p1_oauth2, ".*", {git, "https://github.com/processone/p1_oauth2", {tag, "0.6.2"}}},
{luerl, ".*", {git, "https://github.com/rvirding/luerl", {tag, "v0.2"}}},
+ {jose, ".*", {git, "git://github.com/potatosalad/erlang-jose.git", {tag, "1.8.4"}}},
{fs, ".*", {git, "https://github.com/synrc/fs.git", {tag, "2.12.0"}}},
{if_var_true, stun, {stun, ".*", {git, "https://github.com/processone/stun", {tag, "1.0.15"}}}},
{if_var_true, sip, {esip, ".*", {git, "https://github.com/processone/esip", {tag, "1.0.16"}}}},
diff --git a/src/acme_challenge.erl b/src/acme_challenge.erl
new file mode 100644
index 000000000..f4fde4e73
--- /dev/null
+++ b/src/acme_challenge.erl
@@ -0,0 +1,145 @@
+-module(acme_challenge).
+
+-export ([key_authorization/2,
+ solve_challenge/3,
+ process/2,
+ register_hooks/1,
+ unregister_hooks/1,
+ acme_handler/3
+ ]).
+%% Challenge Types
+%% ================
+%% 1. http-01: https://tools.ietf.org/html/draft-ietf-acme-acme-05#section-7.2
+%% 2. dns-01: https://tools.ietf.org/html/draft-ietf-acme-acme-05#section-7.3
+%% 3. tls-sni-01: https://tools.ietf.org/html/draft-ietf-acme-acme-05#section-7.4
+%% 4. (?) oob-01: https://tools.ietf.org/html/draft-ietf-acme-acme-05#section-7.5
+
+-include("ejabberd.hrl").
+-include("logger.hrl").
+-include("xmpp.hrl").
+-include("ejabberd_http.hrl").
+-include("ejabberd_acme.hrl").
+
+%% This is the default endpoint for the http challenge
+%% This hooks is called from ejabberd_http
+acme_handler(Handlers, _Host, Request) ->
+ case Request#request.path of
+ [<<".well-known">>|_] ->
+ [{[<<".well-known">>],acme_challenge}|Handlers];
+ _ ->
+ Handlers
+ end.
+
+%% TODO: Maybe validate request here??
+process(LocalPath, _Request) ->
+ Result = ets_get_key_authorization(LocalPath),
+ {200,
+ [{<<"Content-Type">>, <<"text/plain">>}],
+ Result}.
+
+register_hooks(_Domain) ->
+ ?INFO_MSG("Registering hook for ACME HTTP headers", []),
+ ejabberd_hooks:add(http_request_handlers, ?MODULE, acme_handler, 50).
+
+unregister_hooks(_Domain) ->
+ ?INFO_MSG("Unregistering hook for ACME HTTP headers", []),
+ ejabberd_hooks:delete(http_request_handlers, ?MODULE, acme_handler, 50).
+
+-spec key_authorization(bitstring(), jose_jwk:key()) -> bitstring().
+key_authorization(Token, Key) ->
+ Thumbprint = jose_jwk:thumbprint(Key),
+ KeyAuthorization = erlang:iolist_to_binary([Token, <<".">>, Thumbprint]),
+ KeyAuthorization.
+
+-spec parse_challenge({proplist()}) -> {ok, acme_challenge()} | {error, _}.
+parse_challenge(Challenge0) ->
+ try
+ {Challenge} = Challenge0,
+ {<<"type">>,Type} = proplists:lookup(<<"type">>, Challenge),
+ {<<"status">>,Status} = proplists:lookup(<<"status">>, Challenge),
+ {<<"uri">>,Uri} = proplists:lookup(<<"uri">>, Challenge),
+ {<<"token">>,Token} = proplists:lookup(<<"token">>, Challenge),
+ Res =
+ #challenge{
+ type = Type,
+ status = list_to_atom(bitstring_to_list(Status)),
+ uri = bitstring_to_list(Uri),
+ token = Token
+ },
+ {ok, Res}
+ catch
+ _:Error ->
+ {error, Error}
+ end.
+
+
+
+-spec solve_challenge(bitstring(), [{proplist()}], _) ->
+ {ok, url(), bitstring()} | {error, _}.
+solve_challenge(ChallengeType, Challenges, Options) ->
+ ParsedChallenges = [parse_challenge(Chall) || Chall <- Challenges],
+ case lists:any(fun is_error/1, ParsedChallenges) of
+ true ->
+ ?ERROR_MSG("Error parsing challenges: ~p~n", [Challenges]),
+ {error, parse_challenge};
+ false ->
+ case [C || {ok, C} <- ParsedChallenges, is_challenge_type(ChallengeType, C)] of
+ [Challenge] ->
+ solve_challenge1(Challenge, Options);
+ _ ->
+ ?ERROR_MSG("Challenge ~p not found in challenges: ~p~n", [ChallengeType, Challenges]),
+ {error, not_found}
+ end
+ end.
+
+-spec solve_challenge1(acme_challenge(), {jose_jwk:key(), string()}) ->
+ {ok, url(), bitstring()} | {error, _}.
+solve_challenge1(Chal = #challenge{type = <<"http-01">>, token=Tkn}, Key) ->
+ KeyAuthz = key_authorization(Tkn, Key),
+ %% save_key_authorization(Chal, Tkn, KeyAuthz, HttpDir);
+ ets_put_key_authorization(Tkn, KeyAuthz),
+ {ok, Chal#challenge.uri, KeyAuthz};
+solve_challenge1(Challenge, _Key) ->
+ ?ERROR_MSG("Unkown Challenge Type: ~p", [Challenge]),
+ {error, unknown_challenge}.
+
+
+-spec ets_put_key_authorization(bitstring(), bitstring()) -> ok.
+ets_put_key_authorization(Tkn, KeyAuthz) ->
+ Tab = ets_get_acme_table(),
+ Key = [<<"acme-challenge">>, Tkn],
+ ets:insert(Tab, {Key, KeyAuthz}),
+ ok.
+
+-spec ets_get_key_authorization([bitstring()]) -> bitstring().
+ets_get_key_authorization(Key) ->
+ Tab = ets_get_acme_table(),
+ case ets:lookup(Tab, Key) of
+ [{Key, KeyAuthz}] ->
+ ets:delete(Tab, Key),
+ KeyAuthz;
+ _ ->
+ ?ERROR_MSG("Unable to serve key authorization in: ~p", [Key]),
+ <<"">>
+ end.
+
+-spec ets_get_acme_table() -> atom().
+ets_get_acme_table() ->
+ case ets:info(acme) of
+ undefined ->
+ ets:new(acme, [named_table, public]);
+ _ ->
+ acme
+ end.
+
+%% Useful functions
+
+is_challenge_type(DesiredType, #challenge{type = Type}) when DesiredType =:= Type ->
+ true;
+is_challenge_type(_DesiredType, #challenge{type = _Type}) ->
+ false.
+
+-spec is_error({'error', _}) -> 'true';
+ ({'ok', _}) -> 'false'.
+is_error({error, _}) -> true;
+is_error(_) -> false.
diff --git a/src/ejabberd_acme.erl b/src/ejabberd_acme.erl
new file mode 100644
index 000000000..e9636f1e5
--- /dev/null
+++ b/src/ejabberd_acme.erl
@@ -0,0 +1,1166 @@
+-module (ejabberd_acme).
+
+-export([%% Ejabberdctl Commands
+ get_certificates/1,
+ renew_certificates/0,
+ list_certificates/1,
+ revoke_certificate/1,
+ %% Command Options Validity
+ is_valid_account_opt/1,
+ is_valid_verbose_opt/1,
+ is_valid_domain_opt/1,
+ is_valid_revoke_cert/1,
+ %% Called by ejabberd_pkix
+ certificate_exists/1,
+ %% Key Related
+ generate_key/0,
+ to_public/1
+ ]).
+
+-include("ejabberd.hrl").
+-include("logger.hrl").
+-include("xmpp.hrl").
+
+-include("ejabberd_acme.hrl").
+-include_lib("public_key/include/public_key.hrl").
+
+-export([opt_type/1]).
+
+-behavior(ejabberd_config).
+
+%%
+%% Default ACME configuration
+%%
+
+-define(DEFAULT_CONFIG_CONTACT, <<"mailto:example-admin@example.com">>).
+-define(DEFAULT_CONFIG_CA_URL, "https://acme-v01.api.letsencrypt.org").
+
+%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%
+%%
+%% Command Functions
+%%
+%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%
+
+%%
+%% Check Validity of command options
+%%
+
+-spec is_valid_account_opt(string()) -> boolean().
+is_valid_account_opt("old-account") -> true;
+is_valid_account_opt("new-account") -> true;
+is_valid_account_opt(_) -> false.
+
+-spec is_valid_verbose_opt(string()) -> boolean().
+is_valid_verbose_opt("plain") -> true;
+is_valid_verbose_opt("verbose") -> true;
+is_valid_verbose_opt(_) -> false.
+
+%% TODO: Make this check more complicated
+-spec is_valid_domain_opt(string()) -> boolean().
+is_valid_domain_opt("all") -> true;
+is_valid_domain_opt(DomainString) ->
+ case parse_domain_string(DomainString) of
+ [] ->
+ false;
+ _SeparatedDomains ->
+ true
+ end.
+
+-spec is_valid_revoke_cert(string()) -> boolean().
+is_valid_revoke_cert(DomainOrFile) ->
+ lists:prefix("file:", DomainOrFile) orelse
+ lists:prefix("domain:", DomainOrFile).
+
+
+
+%%
+%% Get Certificate
+%%
+
+-spec get_certificates(domains_opt()) -> string() | {'error', _}.
+get_certificates(Domains) ->
+ try
+ CAUrl = get_config_ca_url(),
+ get_certificates0(CAUrl, Domains)
+ catch
+ throw:Throw ->
+ Throw;
+ E:R ->
+ ?ERROR_MSG("Unknown ~p:~p, ~p", [E, R, erlang:get_stacktrace()]),
+ {error, get_certificates}
+ end.
+
+-spec get_certificates0(url(), domains_opt()) -> string().
+get_certificates0(CAUrl, Domains) ->
+ %% Check if an account exists or create another one
+ {ok, _AccId, PrivateKey} = retrieve_or_create_account(CAUrl),
+
+ get_certificates1(CAUrl, Domains, PrivateKey).
+
+-spec retrieve_or_create_account(url()) -> {'ok', string(), jose_jwk:key()}.
+retrieve_or_create_account(CAUrl) ->
+ case read_account_persistent() of
+ none ->
+ create_save_new_account(CAUrl);
+
+ {ok, AccId, CAUrl, PrivateKey} ->
+ {ok, AccId, PrivateKey};
+ {ok, _AccId, _, _PrivateKey} ->
+ create_save_new_account(CAUrl)
+ end.
+
+
+-spec get_certificates1(url(), domains_opt(), jose_jwk:key()) -> string().
+get_certificates1(CAUrl, "all", PrivateKey) ->
+ Hosts = get_config_hosts(),
+ get_certificates2(CAUrl, PrivateKey, Hosts);
+get_certificates1(CAUrl, DomainString, PrivateKey) ->
+ Domains = parse_domain_string(DomainString),
+ Hosts = [list_to_bitstring(D) || D <- Domains],
+ get_certificates2(CAUrl, PrivateKey, Hosts).
+
+-spec get_certificates2(url(), jose_jwk:key(), [bitstring()]) -> string().
+get_certificates2(CAUrl, PrivateKey, Hosts) ->
+ %% Get a certificate for each host
+ PemCertKeys = [get_certificate(CAUrl, Host, PrivateKey) || Host <- Hosts],
+
+ %% Save Certificates
+ SavedCerts = [save_certificate(Cert) || Cert <- PemCertKeys],
+
+ %% Format the result to send back to ejabberdctl
+ format_get_certificates_result(SavedCerts).
+
+-spec format_get_certificates_result([{'ok', bitstring(), _} |
+ {'error', bitstring(), _}]) ->
+ string().
+format_get_certificates_result(Certs) ->
+ Cond = lists:all(fun(Cert) ->
+ not is_error(Cert)
+ end, Certs),
+ %% FormattedCerts = string:join([format_get_certificate(C) || C <- Certs], "\n"),
+ FormattedCerts = str:join([format_get_certificate(C) || C <- Certs], $\n),
+ case Cond of
+ true ->
+ Result = io_lib:format("Success:~n~s", [FormattedCerts]),
+ lists:flatten(Result);
+ _ ->
+ Result = io_lib:format("Error with one or more certificates~n~s", [FormattedCerts]),
+ lists:flatten(Result)
+ end.
+
+-spec format_get_certificate({'ok', bitstring(), _} |
+ {'error', bitstring(), _}) ->
+ string().
+format_get_certificate({ok, Domain, saved}) ->
+ io_lib:format(" Certificate for domain: \"~s\" acquired and saved", [Domain]);
+format_get_certificate({ok, Domain, not_found}) ->
+ io_lib:format(" Certificate for domain: \"~s\" not found, so it was not renewed", [Domain]);
+format_get_certificate({ok, Domain, exists}) ->
+ io_lib:format(" Certificate for domain: \"~s\" is not close to expiring", [Domain]);
+format_get_certificate({error, Domain, Reason}) ->
+ io_lib:format(" Error for domain: \"~s\", with reason: \'~s\'", [Domain, Reason]).
+
+-spec get_certificate(url(), bitstring(), jose_jwk:key()) ->
+ {'ok', bitstring(), pem()} |
+ {'error', bitstring(), _}.
+get_certificate(CAUrl, DomainName, PrivateKey) ->
+ try
+ AllSubDomains = find_all_sub_domains(DomainName),
+ lists:foreach(
+ fun(Domain) ->
+ {ok, _Authz} = create_new_authorization(CAUrl, Domain, PrivateKey)
+ end, [DomainName|AllSubDomains]),
+ create_new_certificate(CAUrl, {DomainName, AllSubDomains}, PrivateKey)
+ catch
+ throw:Throw ->
+ Throw;
+ E:R ->
+ ?ERROR_MSG("Unknown ~p:~p, ~p", [E, R, erlang:get_stacktrace()]),
+ {error, DomainName, get_certificate}
+ end.
+
+-spec create_save_new_account(url()) -> {'ok', string(), jose_jwk:key()} | no_return().
+create_save_new_account(CAUrl) ->
+ %% Get contact from configuration file
+ Contact = get_config_contact(),
+
+ %% Generate a Key
+ PrivateKey = generate_key(),
+
+ %% Create a new account
+ {ok, Id} = create_new_account(CAUrl, Contact, PrivateKey),
+
+ %% Write Persistent Data
+ ok = write_account_persistent({Id, CAUrl, PrivateKey}),
+
+ {ok, Id, PrivateKey}.
+
+%% TODO:
+%% Find a way to ask the user if he accepts the TOS
+-spec create_new_account(url(), bitstring(), jose_jwk:key()) -> {'ok', string()} |
+ no_return().
+create_new_account(CAUrl, Contact, PrivateKey) ->
+ try
+ {ok, Dirs, Nonce0} = ejabberd_acme_comm:directory(CAUrl),
+ Req0 = [{ <<"contact">>, [Contact]}],
+ {ok, {TOS, Account}, Nonce1} =
+ ejabberd_acme_comm:new_account(Dirs, PrivateKey, Req0, Nonce0),
+ {<<"id">>, AccIdInt} = lists:keyfind(<<"id">>, 1, Account),
+ AccId = integer_to_list(AccIdInt),
+ Req1 = [{ <<"agreement">>, list_to_bitstring(TOS)}],
+ {ok, _Account2, _Nonce2} =
+ ejabberd_acme_comm:update_account({CAUrl, AccId}, PrivateKey, Req1, Nonce1),
+ {ok, AccId}
+ catch
+ E:R ->
+ ?ERROR_MSG("Error: ~p creating an account for contact: ~p",
+ [{E,R}, Contact]),
+ throw({error,create_new_account})
+ end.
+
+-spec create_new_authorization(url(), bitstring(), jose_jwk:key()) ->
+ {'ok', proplist()} | no_return().
+create_new_authorization(CAUrl, DomainName, PrivateKey) ->
+ acme_challenge:register_hooks(DomainName),
+ try
+ {ok, Dirs, Nonce0} = ejabberd_acme_comm:directory(CAUrl),
+ Req0 = [{<<"identifier">>,
+ {[{<<"type">>, <<"dns">>},
+ {<<"value">>, DomainName}]}},
+ {<<"existing">>, <<"accept">>}],
+ {ok, {AuthzUrl, Authz}, Nonce1} =
+ ejabberd_acme_comm:new_authz(Dirs, PrivateKey, Req0, Nonce0),
+ {ok, AuthzId} = location_to_id(AuthzUrl),
+
+ Challenges = get_challenges(Authz),
+ {ok, ChallengeUrl, KeyAuthz} =
+ acme_challenge:solve_challenge(<<"http-01">>, Challenges, PrivateKey),
+ {ok, ChallengeId} = location_to_id(ChallengeUrl),
+ Req3 = [{<<"type">>, <<"http-01">>},{<<"keyAuthorization">>, KeyAuthz}],
+ {ok, _SolvedChallenge, _Nonce2} = ejabberd_acme_comm:complete_challenge(
+ {CAUrl, AuthzId, ChallengeId}, PrivateKey, Req3, Nonce1),
+
+ {ok, AuthzValid, _Nonce} = ejabberd_acme_comm:get_authz_until_valid({CAUrl, AuthzId}),
+ {ok, AuthzValid}
+ catch
+ E:R ->
+ ?ERROR_MSG("Error: ~p getting an authorization for domain: ~p~n",
+ [{E,R}, DomainName]),
+ throw({error, DomainName, authorization})
+ after
+ acme_challenge:unregister_hooks(DomainName)
+ end.
+
+-spec create_new_certificate(url(), {bitstring(), [bitstring()]}, jose_jwk:key()) ->
+ {ok, bitstring(), pem()}.
+create_new_certificate(CAUrl, {DomainName, AllSubDomains}, PrivateKey) ->
+ try
+ {ok, Dirs, Nonce0} = ejabberd_acme_comm:directory(CAUrl),
+ CSRSubject = [{commonName, bitstring_to_list(DomainName)}],
+ SANs = [{dNSName, SAN} || SAN <- AllSubDomains],
+ {CSR, CSRKey} = make_csr(CSRSubject, SANs),
+ {NotBefore, NotAfter} = not_before_not_after(),
+ Req =
+ [{<<"csr">>, CSR},
+ {<<"notBefore">>, NotBefore},
+ {<<"NotAfter">>, NotAfter}
+ ],
+ {ok, {_CertUrl, Certificate}, _Nonce1} =
+ ejabberd_acme_comm:new_cert(Dirs, PrivateKey, Req, Nonce0),
+
+ DecodedCert = public_key:pkix_decode_cert(list_to_binary(Certificate), plain),
+ PemEntryCert = public_key:pem_entry_encode('Certificate', DecodedCert),
+
+ {_, CSRKeyKey} = jose_jwk:to_key(CSRKey),
+ PemEntryKey = public_key:pem_entry_encode('ECPrivateKey', CSRKeyKey),
+
+ PemCertKey = public_key:pem_encode([PemEntryKey, PemEntryCert]),
+
+ {ok, DomainName, PemCertKey}
+ catch
+ E:R ->
+ ?ERROR_MSG("Error: ~p getting an authorization for domain: ~p~n",
+ [{E,R}, DomainName]),
+ throw({error, DomainName, certificate})
+ end.
+
+-spec ensure_account_exists(url()) -> {ok, string(), jose_jwk:key()}.
+ensure_account_exists(CAUrl) ->
+ case read_account_persistent() of
+ none ->
+ ?ERROR_MSG("No existing account", []),
+ throw({error, no_old_account});
+ {ok, AccId, CAUrl, PrivateKey} ->
+ {ok, AccId, PrivateKey};
+ {ok, _AccId, OtherCAUrl, _PrivateKey} ->
+ ?ERROR_MSG("Account is connected to another CA: ~s", [OtherCAUrl]),
+ throw({error, account_in_other_CA})
+ end.
+
+
+%%
+%% Renew Certificates
+%%
+-spec renew_certificates() -> string() | {'error', _}.
+renew_certificates() ->
+ try
+ CAUrl = get_config_ca_url(),
+ renew_certificates0(CAUrl)
+ catch
+ throw:Throw ->
+ Throw;
+ E:R ->
+ ?ERROR_MSG("Unknown ~p:~p, ~p", [E, R, erlang:get_stacktrace()]),
+ {error, get_certificates}
+ end.
+
+-spec renew_certificates0(url()) -> string().
+renew_certificates0(CAUrl) ->
+ %% Get the current account
+ {ok, _AccId, PrivateKey} = ensure_account_exists(CAUrl),
+
+ %% Find all hosts that we have certificates for
+ Certs = read_certificates_persistent(),
+
+ %% Get a certificate for each host
+ PemCertKeys = [renew_certificate(CAUrl, Cert, PrivateKey) || Cert <- Certs],
+
+ %% Save Certificates
+ SavedCerts = [save_renewed_certificate(Cert) || Cert <- PemCertKeys],
+
+ %% Format the result to send back to ejabberdctl
+ format_get_certificates_result(SavedCerts).
+
+-spec renew_certificate(url(), {bitstring(), data_cert()}, jose_jwk:key()) ->
+ {'ok', bitstring(), _} |
+ {'error', bitstring(), _}.
+renew_certificate(CAUrl, {DomainName, _} = Cert, PrivateKey) ->
+ case cert_to_expire(Cert) of
+ true ->
+ get_certificate(CAUrl, DomainName, PrivateKey);
+ false ->
+ {ok, DomainName, no_expire}
+ end.
+
+
+-spec cert_to_expire({bitstring(), data_cert()}) -> boolean().
+cert_to_expire({_DomainName, #data_cert{pem = Pem}}) ->
+ Certificate = pem_to_certificate(Pem),
+ Validity = get_utc_validity(Certificate),
+
+ %% 30 days before expiration
+ close_to_expire(Validity, 30).
+
+-spec close_to_expire(string(), integer()) -> boolean().
+close_to_expire(Validity, Days) ->
+ {ValidDate, _ValidTime} = utc_string_to_datetime(Validity),
+ ValidDays = calendar:date_to_gregorian_days(ValidDate),
+
+ {CurrentDate, _CurrentTime} = calendar:universal_time(),
+ CurrentDays = calendar:date_to_gregorian_days(CurrentDate),
+ CurrentDays > ValidDays - Days.
+
+
+
+%%
+%% List Certificates
+%%
+-spec list_certificates(verbose_opt()) -> [string()] | [any()] | {error, _}.
+list_certificates(Verbose) ->
+ try
+ list_certificates0(Verbose)
+ catch
+ throw:Throw ->
+ Throw;
+ E:R ->
+ ?ERROR_MSG("Unknown ~p:~p, ~p", [E, R, erlang:get_stacktrace()]),
+ {error, list_certificates}
+ end.
+
+-spec list_certificates0(verbose_opt()) -> [string()] | [any()].
+list_certificates0(Verbose) ->
+ Certs = read_certificates_persistent(),
+ [format_certificate(DataCert, Verbose) || {_Key, DataCert} <- Certs].
+
+%% TODO: Make this cleaner and more robust
+-spec format_certificate(data_cert(), verbose_opt()) -> string().
+format_certificate(DataCert, Verbose) ->
+ #data_cert{
+ domain = DomainName,
+ pem = PemCert,
+ path = Path
+ } = DataCert,
+
+ try
+ Certificate = pem_to_certificate(PemCert),
+
+ %% Find the commonName
+ _CommonName = get_commonName(Certificate),
+
+ %% Find the notAfter date
+ NotAfter = get_notAfter(Certificate),
+
+ %% Find the subjectAltNames
+ SANs = get_subjectAltNames(Certificate),
+
+ case Verbose of
+ "plain" ->
+ format_certificate_plain(DomainName, SANs, NotAfter, Path);
+ "verbose" ->
+ format_certificate_verbose(DomainName, SANs, NotAfter, PemCert)
+ end
+ catch
+ E:R ->
+ ?ERROR_MSG("Unknown ~p:~p, ~p", [E, R, erlang:get_stacktrace()]),
+ fail_format_certificate(DomainName)
+ end.
+
+-spec format_certificate_plain(bitstring(), [string()], {expired | ok, string()}, string())
+ -> string().
+format_certificate_plain(DomainName, SANs, NotAfter, Path) ->
+ Result = lists:flatten(io_lib:format(
+ " Domain: ~s~n"
+ "~s"
+ " ~s~n"
+ " Path: ~s",
+ [DomainName,
+ lists:flatten([io_lib:format(" SAN: ~s~n", [SAN]) || SAN <- SANs]),
+ format_validity(NotAfter), Path])),
+ Result.
+
+-spec format_certificate_verbose(bitstring(), [string()], {expired | ok, string()}, bitstring())
+ -> string().
+format_certificate_verbose(DomainName, SANs, NotAfter, PemCert) ->
+ Result = lists:flatten(io_lib:format(
+ " Domain: ~s~n"
+ "~s"
+ " ~s~n"
+ " Certificate In PEM format: ~n~s",
+ [DomainName,
+ lists:flatten([io_lib:format(" SAN: ~s~n", [SAN]) || SAN <- SANs]),
+ format_validity(NotAfter), PemCert])),
+ Result.
+
+-spec format_validity({'expired' | 'ok', string()}) -> string().
+format_validity({expired, NotAfter}) ->
+ io_lib:format("Expired at: ~s UTC", [NotAfter]);
+format_validity({ok, NotAfter}) ->
+ io_lib:format("Valid until: ~s UTC", [NotAfter]).
+
+-spec fail_format_certificate(bitstring()) -> string().
+fail_format_certificate(DomainName) ->
+ Result = lists:flatten(io_lib:format(
+ " Domain: ~s~n"
+ " Failed to format Certificate",
+ [DomainName])),
+ Result.
+
+-spec get_commonName(#'Certificate'{}) -> string().
+get_commonName(#'Certificate'{tbsCertificate = TbsCertificate}) ->
+ #'TBSCertificate'{
+ subject = {rdnSequence, SubjectList}
+ } = TbsCertificate,
+
+ %% TODO: Not the best way to find the commonName
+ ShallowSubjectList = [Attribute || [Attribute] <- SubjectList],
+ {_, _, CommonName} = lists:keyfind(attribute_oid(commonName), 2, ShallowSubjectList),
+
+ %% TODO: Remove the length-encoding from the commonName before returning it
+ CommonName.
+
+-spec get_notAfter(#'Certificate'{}) -> {expired | ok, string()}.
+get_notAfter(Certificate) ->
+ UtcTime = get_utc_validity(Certificate),
+ %% TODO: Find a library function to decode utc time
+ [Y1,Y2,MO1,MO2,D1,D2,H1,H2,MI1,MI2,S1,S2,$Z] = UtcTime,
+ YEAR = case list_to_integer([Y1,Y2]) >= 50 of
+ true -> "19" ++ [Y1,Y2];
+ _ -> "20" ++ [Y1,Y2]
+ end,
+ NotAfter = lists:flatten(io_lib:format("~s-~s-~s ~s:~s:~s",
+ [YEAR, [MO1,MO2], [D1,D2],
+ [H1,H2], [MI1,MI2], [S1,S2]])),
+
+ case close_to_expire(UtcTime, 0) of
+ true ->
+ {expired, NotAfter};
+ false ->
+ {ok, NotAfter}
+ end.
+
+-spec get_subjectAltNames(#'Certificate'{}) -> [string()].
+get_subjectAltNames(#'Certificate'{tbsCertificate = TbsCertificate}) ->
+ #'TBSCertificate'{
+ extensions = Exts
+ } = TbsCertificate,
+
+ EncodedSANs = [Val || #'Extension'{extnID = Oid, extnValue = Val} <- Exts,
+ Oid =:= attribute_oid(subjectAltName)],
+
+ lists:flatmap(
+ fun(EncSAN) ->
+ SANs0 = public_key:der_decode('SubjectAltName', EncSAN),
+ [Name || {dNSName, Name} <- SANs0]
+ end, EncodedSANs).
+
+
+
+-spec get_utc_validity(#'Certificate'{}) -> string().
+get_utc_validity(#'Certificate'{tbsCertificate = TbsCertificate}) ->
+ #'TBSCertificate'{
+ validity = Validity
+ } = TbsCertificate,
+
+ #'Validity'{notAfter = {utcTime, UtcTime}} = Validity,
+ UtcTime.
+
+%%
+%% Revoke Certificate
+%%
+
+-spec revoke_certificate(string()) -> {ok, deleted} | {error, _}.
+revoke_certificate(DomainOrFile) ->
+ try
+ CAUrl = get_config_ca_url(),
+ revoke_certificate0(CAUrl, DomainOrFile)
+ catch
+ throw:Throw ->
+ Throw;
+ E:R ->
+ ?ERROR_MSG("Unknown ~p:~p, ~p", [E, R, erlang:get_stacktrace()]),
+ {error, revoke_certificate}
+ end.
+
+-spec revoke_certificate0(url(), string()) -> {ok, deleted}.
+revoke_certificate0(CAUrl, DomainOrFile) ->
+ ParsedCert = parse_revoke_cert_argument(DomainOrFile),
+ revoke_certificate1(CAUrl, ParsedCert).
+
+-spec revoke_certificate1(url(), {domain, bitstring()} | {file, file:filename()}) ->
+ {ok, deleted}.
+revoke_certificate1(CAUrl, {domain, Domain}) ->
+ case domain_certificate_exists(Domain) of
+ {Domain, Cert = #data_cert{pem=PemCert}} ->
+ ok = revoke_certificate2(CAUrl, PemCert),
+ ok = remove_certificate_persistent(Cert),
+ {ok, deleted};
+ false ->
+ ?ERROR_MSG("Certificate for domain: ~p not found", [Domain]),
+ throw({error, not_found})
+ end;
+revoke_certificate1(CAUrl, {file, File}) ->
+ case file:read_file(File) of
+ {ok, Pem} ->
+ ok = revoke_certificate2(CAUrl, Pem),
+ {ok, deleted};
+ {error, Reason} ->
+ ?ERROR_MSG("Error: ~p reading pem certificate-key file: ~p", [Reason, File]),
+ throw({error, Reason})
+ end.
+
+
+-spec revoke_certificate2(url(), pem()) -> ok.
+revoke_certificate2(CAUrl, PemEncodedCert) ->
+ {Certificate, CertPrivateKey} = prepare_certificate_revoke(PemEncodedCert),
+
+ {ok, Dirs, Nonce} = ejabberd_acme_comm:directory(CAUrl),
+
+ Req = [{<<"certificate">>, Certificate}],
+ {ok, [], _Nonce1} = ejabberd_acme_comm:revoke_cert(Dirs, CertPrivateKey, Req, Nonce),
+ ok.
+
+-spec parse_revoke_cert_argument(string()) -> {domain, bitstring()} | {file, file:filename()}.
+parse_revoke_cert_argument([$f, $i, $l, $e, $:|File]) ->
+ {file, File};
+parse_revoke_cert_argument([$d, $o, $m, $a, $i, $n, $: | Domain]) ->
+ {domain, list_to_bitstring(Domain)}.
+
+-spec prepare_certificate_revoke(pem()) -> {bitstring(), jose_jwk:key()}.
+prepare_certificate_revoke(PemEncodedCert) ->
+ PemList = public_key:pem_decode(PemEncodedCert),
+ PemCertEnc = lists:keyfind('Certificate', 1, PemList),
+ PemCert = public_key:pem_entry_decode(PemCertEnc),
+ DerCert = public_key:der_encode('Certificate', PemCert),
+ Base64Cert = base64url:encode(DerCert),
+
+ {ok, Key} = find_private_key_in_pem(PemEncodedCert),
+ {Base64Cert, Key}.
+
+-spec domain_certificate_exists(bitstring()) -> {bitstring(), data_cert()} | false.
+domain_certificate_exists(Domain) ->
+ Certs = read_certificates_persistent(),
+ lists:keyfind(Domain, 1, Certs).
+
+
+%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%
+%%
+%% Called by ejabberd_pkix to check
+%% if a certificate exists for a
+%% specific host
+%%
+%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%
+
+-spec certificate_exists(bitstring()) -> {true, file:filename()} | false.
+certificate_exists(Host) ->
+ Certificates = read_certificates_persistent(),
+ case lists:keyfind(Host, 1 , Certificates) of
+ false ->
+ false;
+ {Host, #data_cert{path=Path}} ->
+ {true, Path}
+ end.
+
+
+%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%
+%%
+%% Certificate Request Functions
+%%
+%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%
+
+%% For now we accept only generating a key of
+%% specific type for signing the csr
+
+-spec make_csr(proplist(), [{dNSName, bitstring()}])
+ -> {binary(), jose_jwk:key()}.
+make_csr(Attributes, SANs) ->
+ Key = generate_key(),
+ {_, KeyKey} = jose_jwk:to_key(Key),
+ KeyPub = to_public(Key),
+ try
+ SubPKInfoAlgo = subject_pk_info_algo(KeyPub),
+ {ok, RawBinPubKey} = raw_binary_public_key(KeyPub),
+ SubPKInfo = subject_pk_info(SubPKInfoAlgo, RawBinPubKey),
+ {ok, Subject} = attributes_from_list(Attributes),
+ ExtensionRequest = extension_request(SANs),
+ CRI = certificate_request_info(SubPKInfo, Subject, ExtensionRequest),
+ {ok, EncodedCRI} = der_encode(
+ 'CertificationRequestInfo',
+ CRI),
+ SignedCRI = public_key:sign(EncodedCRI, 'sha256', KeyKey),
+ SignatureAlgo = signature_algo(Key, 'sha256'),
+ CSR = certification_request(CRI, SignatureAlgo, SignedCRI),
+ {ok, DerCSR} = der_encode(
+ 'CertificationRequest',
+ CSR),
+ Result = base64url:encode(DerCSR),
+ {Result, Key}
+ catch
+ _:{badmatch, {error, bad_public_key}} ->
+ {error, bad_public_key};
+ _:{badmatch, {error, bad_attributes}} ->
+ {error, bad_public_key};
+ _:{badmatch, {error, der_encode}} ->
+ {error, der_encode}
+ end.
+
+
+
+subject_pk_info_algo(_KeyPub) ->
+ #'SubjectPublicKeyInfoAlgorithm'{
+ algorithm = ?'id-ecPublicKey',
+ parameters = {asn1_OPENTYPE,<<6,8,42,134,72,206,61,3,1,7>>}
+ }.
+
+subject_pk_info(Algo, RawBinPubKey) ->
+ #'SubjectPublicKeyInfo-PKCS-10'{
+ algorithm = Algo,
+ subjectPublicKey = RawBinPubKey
+ }.
+
+extension(SANs) ->
+ #'Extension'{
+ extnID = attribute_oid(subjectAltName),
+ critical = false,
+ extnValue = public_key:der_encode('SubjectAltName', SANs)}.
+
+extension_request(SANs) ->
+ #'AttributePKCS-10'{
+ type = ?'pkcs-9-at-extensionRequest',
+ values = [{'asn1_OPENTYPE',
+ public_key:der_encode(
+ 'ExtensionRequest',
+ [extension(SANs)])}]
+ }.
+
+certificate_request_info(SubPKInfo, Subject, ExtensionRequest) ->
+ #'CertificationRequestInfo'{
+ version = 0,
+ subject = Subject,
+ subjectPKInfo = SubPKInfo,
+ attributes = [ExtensionRequest]
+ }.
+
+signature_algo(_Key, _Hash) ->
+ #'CertificationRequest_signatureAlgorithm'{
+ algorithm = ?'ecdsa-with-SHA256',
+ parameters = asn1_NOVALUE
+ }.
+
+certification_request(CRI, SignatureAlgo, SignedCRI) ->
+ #'CertificationRequest'{
+ certificationRequestInfo = CRI,
+ signatureAlgorithm = SignatureAlgo,
+ signature = SignedCRI
+ }.
+
+raw_binary_public_key(KeyPub) ->
+ try
+ {_, RawPubKey} = jose_jwk:to_key(KeyPub),
+ {{_, RawBinPubKey}, _} = RawPubKey,
+ {ok, RawBinPubKey}
+ catch
+ _:_ ->
+ ?ERROR_MSG("Bad public key: ~p~n", [KeyPub]),
+ {error, bad_public_key}
+ end.
+
+der_encode(Type, Term) ->
+ try
+ {ok, public_key:der_encode(Type, Term)}
+ catch
+ _:_ ->
+ ?ERROR_MSG("Cannot DER encode: ~p, with asn1type: ~p", [Term, Type]),
+ {error, der_encode}
+ end.
+
+%%
+%% Attributes Parser
+%%
+
+attributes_from_list(Attrs) ->
+ ParsedAttrs = [attribute_parser_fun(Attr) || Attr <- Attrs],
+ case lists:any(fun is_error/1, ParsedAttrs) of
+ true ->
+ {error, bad_attributes};
+ false ->
+ {ok, {rdnSequence, [[PAttr] || PAttr <- ParsedAttrs]}}
+ end.
+
+attribute_parser_fun({AttrName, AttrVal}) ->
+ try
+ #'AttributeTypeAndValue'{
+ type = attribute_oid(AttrName),
+ %% TODO: Check if every attribute should be encoded as
+ %% common name. Actually it doesn't matter in
+ %% practice. Only in theory in order to have cleaner code.
+ value = public_key:der_encode('X520CommonName', {printableString, AttrVal})
+ %% value = length_bitstring(list_to_bitstring(AttrVal))
+ }
+ catch
+ _:_ ->
+ ?ERROR_MSG("Bad attribute: ~p~n", [{AttrName, AttrVal}]),
+ {error, bad_attributes}
+ end.
+
+-spec attribute_oid(atom()) -> tuple() | no_return().
+attribute_oid(commonName) -> ?'id-at-commonName';
+attribute_oid(countryName) -> ?'id-at-countryName';
+attribute_oid(stateOrProvinceName) -> ?'id-at-stateOrProvinceName';
+attribute_oid(localityName) -> ?'id-at-localityName';
+attribute_oid(organizationName) -> ?'id-at-organizationName';
+attribute_oid(subjectAltName) -> ?'id-ce-subjectAltName';
+attribute_oid(_) -> error(bad_attributes).
+
+
+
+%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%
+%%
+%% Useful funs
+%%
+%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%
+
+-spec location_to_id(url()) -> {ok, string()} | {error, not_found}.
+location_to_id(Url0) ->
+ Url = string:strip(Url0, right, $/),
+ case string:rchr(Url, $/) of
+ 0 ->
+ ?ERROR_MSG("Couldn't find id in url: ~p~n", [Url]),
+ {error, not_found};
+ Ind ->
+ {ok, string:sub_string(Url, Ind+1)}
+ end.
+
+-spec get_challenges(proplist()) -> [{proplist()}].
+get_challenges(Body) ->
+ {<<"challenges">>, Challenges} = proplists:lookup(<<"challenges">>, Body),
+ Challenges.
+
+-spec not_before_not_after() -> {binary(), binary()}.
+not_before_not_after() ->
+ {Date, Time} = calendar:universal_time(),
+ NotBefore = encode_calendar_datetime({Date, Time}),
+ %% The certificate will be valid for 90 Days after today
+ AfterDate = add_days_to_date(90, Date),
+ NotAfter = encode_calendar_datetime({AfterDate, Time}),
+ {NotBefore, NotAfter}.
+
+-spec to_public(jose_jwk:key()) -> jose_jwk:key().
+to_public(PrivateKey) ->
+ jose_jwk:to_public(PrivateKey).
+
+-spec pem_to_certificate(pem()) -> #'Certificate'{}.
+pem_to_certificate(Pem) ->
+ PemList = public_key:pem_decode(Pem),
+ PemEntryCert = lists:keyfind('Certificate', 1, PemList),
+ Certificate = public_key:pem_entry_decode(PemEntryCert),
+ Certificate.
+
+-spec add_days_to_date(integer(), calendar:date()) -> calendar:date().
+add_days_to_date(Days, Date) ->
+ Date1 = calendar:date_to_gregorian_days(Date),
+ calendar:gregorian_days_to_date(Date1 + Days).
+
+-spec encode_calendar_datetime(calendar:datetime()) -> binary().
+encode_calendar_datetime({{Year, Month, Day}, {Hour, Minute, Second}}) ->
+ list_to_binary(io_lib:format("~4..0B-~2..0B-~2..0BT"
+ "~2..0B:~2..0B:~2..0BZ",
+ [Year, Month, Day, Hour, Minute, Second])).
+
+%% TODO: Find a better and more robust way to parse the utc string
+-spec utc_string_to_datetime(string()) -> calendar:datetime().
+utc_string_to_datetime(UtcString) ->
+ try
+ [Y1,Y2,MO1,MO2,D1,D2,H1,H2,MI1,MI2,S1,S2,$Z] = UtcString,
+ Year = list_to_integer("20" ++ [Y1,Y2]),
+ Month = list_to_integer([MO1, MO2]),
+ Day = list_to_integer([D1,D2]),
+ Hour = list_to_integer([H1,H2]),
+ Minute = list_to_integer([MI1,MI2]),
+ Second = list_to_integer([S1,S2]),
+ {{Year, Month, Day}, {Hour, Minute, Second}}
+ catch
+ _:_ ->
+ ?ERROR_MSG("Unable to parse UTC string", []),
+ throw({error, utc_string_to_datetime})
+ end.
+
+-spec find_private_key_in_pem(pem()) -> {ok, jose_jwk:key()} | false.
+find_private_key_in_pem(Pem) ->
+ PemList = public_key:pem_decode(Pem),
+ case find_private_key_in_pem1(private_key_types(), PemList) of
+ false ->
+ false;
+ PemKey ->
+ Key = public_key:pem_entry_decode(PemKey),
+ JoseKey = jose_jwk:from_key(Key),
+ {ok, JoseKey}
+ end.
+
+
+-spec find_private_key_in_pem1([public_key:pki_asn1_type()],
+ [public_key:pem_entry()]) ->
+ public_key:pem_entry() | false.
+find_private_key_in_pem1([], _PemList) ->
+ false;
+find_private_key_in_pem1([Type|Types], PemList) ->
+ case lists:keyfind(Type, 1, PemList) of
+ false ->
+ find_private_key_in_pem1(Types, PemList);
+ Key ->
+ Key
+ end.
+
+
+-spec parse_domain_string(string()) -> [string()].
+parse_domain_string(DomainString) ->
+ string:tokens(DomainString, ";").
+
+-spec private_key_types() -> [public_key:pki_asn1_type()].
+private_key_types() ->
+ ['RSAPrivateKey',
+ 'DSAPrivateKey',
+ 'ECPrivateKey'].
+
+-spec find_all_sub_domains(bitstring()) -> [bitstring()].
+find_all_sub_domains(DomainName) ->
+ AllRoutes = ejabberd_router:get_all_routes(),
+ DomainLen = size(DomainName),
+ [Route || Route <- AllRoutes,
+ binary:longest_common_suffix([DomainName, Route])
+ =:= DomainLen].
+
+
+-spec is_error(_) -> boolean().
+is_error({error, _}) -> true;
+is_error({error, _, _}) -> true;
+is_error(_) -> false.
+
+%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%
+%%
+%% Handle the persistent data structure
+%%
+%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%
+-spec data_empty() -> [].
+data_empty() ->
+ [].
+
+%%
+%% Account
+%%
+
+-spec data_get_account(acme_data()) -> {ok, list(), url(), jose_jwk:key()} | none.
+data_get_account(Data) ->
+ case lists:keyfind(account, 1, Data) of
+ {account, #data_acc{id = AccId, ca_url = CAUrl, key = PrivateKey}} ->
+ {ok, AccId, CAUrl, PrivateKey};
+ false ->
+ none
+ end.
+
+-spec data_set_account(acme_data(), {list(), url(), jose_jwk:key()}) -> acme_data().
+data_set_account(Data, {AccId, CAUrl, PrivateKey}) ->
+ NewAcc = {account, #data_acc{id = AccId, ca_url = CAUrl, key = PrivateKey}},
+ lists:keystore(account, 1, Data, NewAcc).
+
+%%
+%% Certificates
+%%
+
+-spec data_get_certificates(acme_data()) -> data_certs().
+data_get_certificates(Data) ->
+ case lists:keyfind(certs, 1, Data) of
+ {certs, Certs} ->
+ Certs;
+ false ->
+ []
+ end.
+
+-spec data_set_certificates(acme_data(), data_certs()) -> acme_data().
+data_set_certificates(Data, NewCerts) ->
+ lists:keystore(certs, 1, Data, {certs, NewCerts}).
+
+%% ATM we preserve one certificate for each domain
+-spec data_add_certificate(acme_data(), data_cert()) -> acme_data().
+data_add_certificate(Data, DataCert = #data_cert{domain=Domain}) ->
+ Certs = data_get_certificates(Data),
+ NewCerts = lists:keystore(Domain, 1, Certs, {Domain, DataCert}),
+ data_set_certificates(Data, NewCerts).
+
+-spec data_remove_certificate(acme_data(), data_cert()) -> acme_data().
+data_remove_certificate(Data, _DataCert = #data_cert{domain=Domain}) ->
+ Certs = data_get_certificates(Data),
+ NewCerts = lists:keydelete(Domain, 1, Certs),
+ data_set_certificates(Data, NewCerts).
+
+
+%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%
+%%
+%% Handle Config and Persistence Files
+%%
+%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%
+
+-spec persistent_file() -> file:filename().
+persistent_file() ->
+ MnesiaDir = mnesia:system_info(directory),
+ filename:join(MnesiaDir, "acme.DAT").
+
+%% The persistent file should be read and written only by its owner
+-spec persistent_file_mode() -> 384.
+persistent_file_mode() ->
+ 8#400 + 8#200.
+
+-spec read_persistent() -> {ok, acme_data()} | no_return().
+read_persistent() ->
+ case file:read_file(persistent_file()) of
+ {ok, Binary} ->
+ {ok, binary_to_term(Binary)};
+ {error, enoent} ->
+ create_persistent(),
+ {ok, data_empty()};
+ {error, Reason} ->
+ ?ERROR_MSG("Error: ~p reading acme data file", [Reason]),
+ throw({error, Reason})
+ end.
+
+-spec write_persistent(acme_data()) -> ok | no_return().
+write_persistent(Data) ->
+ Binary = term_to_binary(Data),
+ case file:write_file(persistent_file(), Binary) of
+ ok -> ok;
+ {error, Reason} ->
+ ?ERROR_MSG("Error: ~p writing acme data file", [Reason]),
+ throw({error, Reason})
+ end.
+
+-spec create_persistent() -> ok | no_return().
+create_persistent() ->
+ Binary = term_to_binary(data_empty()),
+ case file:write_file(persistent_file(), Binary) of
+ ok ->
+ case file:change_mode(persistent_file(), persistent_file_mode()) of
+ ok -> ok;
+ {error, Reason} ->
+ ?ERROR_MSG("Error: ~p changing acme data file mode", [Reason]),
+ throw({error, Reason})
+ end;
+ {error, Reason} ->
+ ?ERROR_MSG("Error: ~p creating acme data file", [Reason]),
+ throw({error, Reason})
+ end.
+
+-spec write_account_persistent({list(), url(), jose_jwk:key()}) -> ok | no_return().
+write_account_persistent({AccId, CAUrl, PrivateKey}) ->
+ {ok, Data} = read_persistent(),
+ NewData = data_set_account(Data, {AccId, CAUrl, PrivateKey}),
+ ok = write_persistent(NewData).
+
+-spec read_account_persistent() -> {ok, list(), url(), jose_jwk:key()} | none.
+read_account_persistent() ->
+ {ok, Data} = read_persistent(),
+ data_get_account(Data).
+
+-spec read_certificates_persistent() -> data_certs().
+read_certificates_persistent() ->
+ {ok, Data} = read_persistent(),
+ data_get_certificates(Data).
+
+-spec add_certificate_persistent(data_cert()) -> ok.
+add_certificate_persistent(DataCert) ->
+ {ok, Data} = read_persistent(),
+ NewData = data_add_certificate(Data, DataCert),
+ ok = write_persistent(NewData).
+
+-spec remove_certificate_persistent(data_cert()) -> ok.
+remove_certificate_persistent(DataCert) ->
+ {ok, Data} = read_persistent(),
+ NewData = data_remove_certificate(Data, DataCert),
+ ok = write_persistent(NewData).
+
+-spec save_certificate({ok, bitstring(), binary()} | {error, _, _}) ->
+ {ok, bitstring(), saved} | {error, bitstring(), _}.
+save_certificate({error, _, _} = Error) ->
+ Error;
+save_certificate({ok, DomainName, Cert}) ->
+ try
+ CertDir = get_config_cert_dir(),
+ DomainString = bitstring_to_list(DomainName),
+ CertificateFile = filename:join([CertDir, DomainString ++ "_cert.pem"]),
+ %% TODO: At some point do the following using a Transaction so
+ %% that there is no certificate saved if it cannot be added in
+ %% certificate persistent storage
+ write_cert(CertificateFile, Cert, DomainName),
+ DataCert = #data_cert{
+ domain = DomainName,
+ pem = Cert,
+ path = CertificateFile
+ },
+ add_certificate_persistent(DataCert),
+ ejabberd_pkix:add_certfile(CertificateFile),
+ {ok, DomainName, saved}
+ catch
+ throw:Throw ->
+ Throw;
+ E:R ->
+ ?ERROR_MSG("Unknown ~p:~p, ~p", [E, R, erlang:get_stacktrace()]),
+ {error, DomainName, saving}
+ end.
+
+-spec save_renewed_certificate({ok, bitstring(), _} | {error, _, _}) ->
+ {ok, bitstring(), _} | {error, bitstring(), _}.
+save_renewed_certificate({error, _, _} = Error) ->
+ Error;
+save_renewed_certificate({ok, _, no_expire} = Cert) ->
+ Cert;
+save_renewed_certificate({ok, DomainName, Cert}) ->
+ save_certificate({ok, DomainName, Cert}).
+
+-spec write_cert(file:filename(), binary(), bitstring()) -> {ok, bitstring(), saved}.
+write_cert(CertificateFile, Cert, DomainName) ->
+ case file:write_file(CertificateFile, Cert) of
+ ok ->
+ {ok, DomainName, saved};
+ {error, Reason} ->
+ ?ERROR_MSG("Error: ~p saving certificate at file: ~p",
+ [Reason, CertificateFile]),
+ throw({error, DomainName, saving})
+ end.
+
+-spec get_config_acme() -> acme_config().
+get_config_acme() ->
+ case ejabberd_config:get_option(acme, undefined) of
+ undefined ->
+ ?WARNING_MSG("No acme configuration has been specified", []),
+ %% throw({error, configuration});
+ [];
+ Acme ->
+ Acme
+ end.
+
+-spec get_config_contact() -> bitstring().
+get_config_contact() ->
+ Acme = get_config_acme(),
+ case lists:keyfind(contact, 1, Acme) of
+ {contact, Contact} ->
+ Contact;
+ false ->
+ ?WARNING_MSG("No contact has been specified in configuration", []),
+ ?DEFAULT_CONFIG_CONTACT
+ %% throw({error, configuration_contact})
+ end.
+
+-spec get_config_ca_url() -> url().
+get_config_ca_url() ->
+ Acme = get_config_acme(),
+ case lists:keyfind(ca_url, 1, Acme) of
+ {ca_url, CAUrl} ->
+ CAUrl;
+ false ->
+ ?ERROR_MSG("No CA url has been specified in configuration", []),
+ ?DEFAULT_CONFIG_CA_URL
+ %% throw({error, configuration_ca_url})
+ end.
+
+
+-spec get_config_hosts() -> [bitstring()].
+get_config_hosts() ->
+ case ejabberd_config:get_option(hosts, undefined) of
+ undefined ->
+ ?ERROR_MSG("No hosts have been specified in configuration", []),
+ throw({error, configuration_hosts});
+ Hosts ->
+ Hosts
+ end.
+
+-spec get_config_cert_dir() -> file:filename().
+get_config_cert_dir() ->
+ case ejabberd_config:get_option(cert_dir, undefined) of
+ undefined ->
+ ?WARNING_MSG("No cert_dir configuration has been specified in configuration", []),
+ mnesia:system_info(directory);
+ %% throw({error, configuration});
+ CertDir ->
+ CertDir
+ end.
+
+
+generate_key() ->
+ jose_jwk:generate_key({ec, secp256r1}).
+
+%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%
+%%
+%% Option Parsing Code
+%%
+%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%
+
+parse_acme_opts(AcmeOpt) ->
+ [parse_acme_opt(Opt) || Opt <- AcmeOpt].
+
+
+parse_acme_opt({ca_url, CaUrl}) when is_bitstring(CaUrl) ->
+ {ca_url, binary_to_list(CaUrl)};
+parse_acme_opt({contact, Contact}) when is_bitstring(Contact) ->
+ {contact, Contact}.
+
+parse_cert_dir_opt(Opt) when is_bitstring(Opt) ->
+ true = filelib:is_dir(Opt),
+ Opt.
+
+-spec opt_type(acme) -> fun((acme_config()) -> (acme_config()));
+ (cert_dir) -> fun((bitstring()) -> (bitstring()));
+ (atom()) -> [atom()].
+opt_type(acme) ->
+ fun parse_acme_opts/1;
+opt_type(cert_dir) ->
+ fun parse_cert_dir_opt/1;
+opt_type(_) ->
+ [acme, cert_dir].
diff --git a/src/ejabberd_acme_comm.erl b/src/ejabberd_acme_comm.erl
new file mode 100644
index 000000000..acd552f7e
--- /dev/null
+++ b/src/ejabberd_acme_comm.erl
@@ -0,0 +1,393 @@
+-module(ejabberd_acme_comm).
+-export([%% Directory
+ directory/1,
+ %% Account
+ new_account/4,
+ update_account/4,
+ get_account/3,
+ delete_account/3,
+ %% Authorization
+ new_authz/4,
+ get_authz/1,
+ complete_challenge/4,
+ %% Authorization polling
+ get_authz_until_valid/1,
+ %% Certificate
+ new_cert/4,
+ get_cert/1,
+ revoke_cert/4
+ %% Not yet implemented
+ %% key_roll_over/5
+ %% delete_authz/3
+ ]).
+
+-include("ejabberd.hrl").
+-include("logger.hrl").
+-include("xmpp.hrl").
+
+-include("ejabberd_acme.hrl").
+-include_lib("public_key/include/public_key.hrl").
+
+-define(REQUEST_TIMEOUT, 5000). % 5 seconds.
+-define(MAX_POLL_REQUESTS, 20).
+-define(POLL_WAIT_TIME, 500). % 500 ms.
+
+%%%
+%%% This module contains functions that implement all necessary http
+%%% requests to the ACME Certificate Authority. Its purpose is to
+%%% facilitate the acme client implementation by separating the
+%%% handling/validating/parsing of all the needed http requests.
+%%%
+
+%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%
+%%
+%% Directory
+%%
+%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%
+
+-spec directory(url()) -> {ok, dirs(), nonce()} | {error, _}.
+directory(CAUrl) ->
+ Url = CAUrl ++ "/directory",
+ prepare_get_request(Url, fun get_dirs/1).
+
+%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%
+%%
+%% Account Handling
+%%
+%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%
+
+-spec new_account(dirs(), jose_jwk:key(), proplist(), nonce()) ->
+ {ok, {url(), proplist()}, nonce()} | {error, _}.
+new_account(Dirs, PrivateKey, Req, Nonce) ->
+ #{"new-reg" := Url} = Dirs,
+ EJson = {[{ <<"resource">>, <<"new-reg">>}] ++ Req},
+ prepare_post_request(Url, PrivateKey, EJson, Nonce, fun get_response_tos/1).
+
+-spec update_account({url(), string()}, jose_jwk:key(), proplist(), nonce()) ->
+ {ok, proplist(), nonce()} | {error, _}.
+update_account({CAUrl, AccId}, PrivateKey, Req, Nonce) ->
+ Url = CAUrl ++ "/acme/reg/" ++ AccId,
+ EJson = {[{ <<"resource">>, <<"reg">>}] ++ Req},
+ prepare_post_request(Url, PrivateKey, EJson, Nonce, fun get_response/1).
+
+-spec get_account({url(), string()}, jose_jwk:key(), nonce()) ->
+ {ok, {url(), proplist()}, nonce()} | {error, _}.
+get_account({CAUrl, AccId}, PrivateKey, Nonce) ->
+ Url = CAUrl ++ "/acme/reg/" ++ AccId,
+ EJson = {[{<<"resource">>, <<"reg">>}]},
+ prepare_post_request(Url, PrivateKey, EJson, Nonce, fun get_response_tos/1).
+
+-spec delete_account({url(), string()}, jose_jwk:key(), nonce()) ->
+ {ok, proplist(), nonce()} | {error, _}.
+delete_account({CAUrl, AccId}, PrivateKey, Nonce) ->
+ Url = CAUrl ++ "/acme/reg/" ++ AccId,
+ EJson =
+ {[{<<"resource">>, <<"reg">>},
+ {<<"status">>, <<"deactivated">>}]},
+ prepare_post_request(Url, PrivateKey, EJson, Nonce, fun get_response/1).
+
+
+%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%
+%%
+%% Authorization Handling
+%%
+%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%
+
+-spec new_authz(dirs(), jose_jwk:key(), proplist(), nonce()) ->
+ {ok, {url(), proplist()}, nonce()} | {error, _}.
+new_authz(Dirs, PrivateKey, Req, Nonce) ->
+ #{"new-authz" := Url} = Dirs,
+ EJson = {[{<<"resource">>, <<"new-authz">>}] ++ Req},
+ prepare_post_request(Url, PrivateKey, EJson, Nonce, fun get_response_location/1).
+
+-spec get_authz({url(), string()}) -> {ok, proplist(), nonce()} | {error, _}.
+get_authz({CAUrl, AuthzId}) ->
+ Url = CAUrl ++ "/acme/authz/" ++ AuthzId,
+ prepare_get_request(Url, fun get_response/1).
+
+-spec complete_challenge({url(), string(), string()}, jose_jwk:key(), proplist(), nonce()) ->
+ {ok, proplist(), nonce()} | {error, _}.
+complete_challenge({CAUrl, AuthzId, ChallId}, PrivateKey, Req, Nonce) ->
+ Url = CAUrl ++ "/acme/challenge/" ++ AuthzId ++ "/" ++ ChallId,
+ EJson = {[{<<"resource">>, <<"challenge">>}] ++ Req},
+ prepare_post_request(Url, PrivateKey, EJson, Nonce, fun get_response/1).
+
+
+%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%
+%%
+%% Certificate Handling
+%%
+%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%
+
+-spec new_cert(dirs(), jose_jwk:key(), proplist(), nonce()) ->
+ {ok, {url(), list()}, nonce()} | {error, _}.
+new_cert(Dirs, PrivateKey, Req, Nonce) ->
+ #{"new-cert" := Url} = Dirs,
+ EJson = {[{<<"resource">>, <<"new-cert">>}] ++ Req},
+ prepare_post_request(Url, PrivateKey, EJson, Nonce, fun get_response_location/1,
+ "application/pkix-cert").
+
+-spec get_cert({url(), string()}) -> {ok, list(), nonce()} | {error, _}.
+get_cert({CAUrl, CertId}) ->
+ Url = CAUrl ++ "/acme/cert/" ++ CertId,
+ prepare_get_request(Url, fun get_response/1, "application/pkix-cert").
+
+-spec revoke_cert(dirs(), jose_jwk:key(), proplist(), nonce()) ->
+ {ok, _, nonce()} | {error, _}.
+revoke_cert(Dirs, PrivateKey, Req, Nonce) ->
+ #{"revoke-cert" := Url} = Dirs,
+ EJson = {[{<<"resource">>, <<"revoke-cert">>}] ++ Req},
+ prepare_post_request(Url, PrivateKey, EJson, Nonce, fun get_response/1,
+ "application/pkix-cert").
+
+
+%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%
+%%
+%% Handle Response Functions
+%%
+%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%
+
+-spec get_dirs({ok, proplist(), proplist()}) -> {ok, map(), nonce()}.
+get_dirs({ok, Head, Return}) ->
+ NewNonce = get_nonce(Head),
+ StrDirectories = [{bitstring_to_list(X), bitstring_to_list(Y)} ||
+ {X, Y} <- Return, is_bitstring(X) andalso is_bitstring(Y)],
+ NewDirs = maps:from_list(StrDirectories),
+ {ok, NewDirs, NewNonce}.
+
+-spec get_response({ok, proplist(), proplist()}) -> {ok, proplist(), nonce()}.
+get_response({ok, Head, Return}) ->
+ NewNonce = get_nonce(Head),
+ {ok, Return, NewNonce}.
+
+-spec get_response_tos({ok, proplist(), proplist()}) -> {ok, {url(), proplist()}, nonce()}.
+get_response_tos({ok, Head, Return}) ->
+ TOSUrl = get_tos(Head),
+ NewNonce = get_nonce(Head),
+ {ok, {TOSUrl, Return}, NewNonce}.
+
+-spec get_response_location({ok, proplist(), proplist()}) -> {ok, {url(), proplist()}, nonce()}.
+get_response_location({ok, Head, Return}) ->
+ Location = get_location(Head),
+ NewNonce = get_nonce(Head),
+ {ok, {Location, Return}, NewNonce}.
+
+
+
+%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%
+%%
+%% Authorization Polling
+%%
+%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%
+
+-spec get_authz_until_valid({url(), string()}) -> {ok, proplist(), nonce()} | {error, _}.
+get_authz_until_valid({CAUrl, AuthzId}) ->
+ get_authz_until_valid({CAUrl, AuthzId}, ?MAX_POLL_REQUESTS).
+
+-spec get_authz_until_valid({url(), string()}, non_neg_integer()) ->
+ {ok, proplist(), nonce()} | {error, _}.
+get_authz_until_valid({_CAUrl, _AuthzId}, 0) ->
+ ?ERROR_MSG("Maximum request limit waiting for validation reached", []),
+ {error, max_request_limit};
+get_authz_until_valid({CAUrl, AuthzId}, N) ->
+ case get_authz({CAUrl, AuthzId}) of
+ {ok, Resp, Nonce} ->
+ case is_authz_valid(Resp) of
+ true ->
+ {ok, Resp, Nonce};
+ false ->
+ timer:sleep(?POLL_WAIT_TIME),
+ get_authz_until_valid({CAUrl, AuthzId}, N-1)
+ end;
+ {error, _} = Err ->
+ Err
+ end.
+
+-spec is_authz_valid(proplist()) -> boolean().
+is_authz_valid(Authz) ->
+ case proplists:lookup(<<"status">>, Authz) of
+ {<<"status">>, <<"valid">>} ->
+ true;
+ _ ->
+ false
+ end.
+
+
+%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%
+%%
+%% Request Functions
+%%
+%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%
+
+%% TODO: Fix the duplicated code at the below 4 functions
+-spec make_post_request(url(), bitstring(), string()) ->
+ {ok, proplist(), proplist()} | {error, _}.
+make_post_request(Url, ReqBody, ResponseType) ->
+ Options = [],
+ HttpOptions = [{timeout, ?REQUEST_TIMEOUT}],
+ case httpc:request(post,
+ {Url, [], "application/jose+json", ReqBody}, HttpOptions, Options) of
+ {ok, {{_, Code, _}, Head, Body}} when Code >= 200, Code =< 299 ->
+ decode_response(Head, Body, ResponseType);
+ Error ->
+ failed_http_request(Error, Url)
+ end.
+
+-spec make_get_request(url(), string()) ->
+ {ok, proplist(), proplist()} | {error, _}.
+make_get_request(Url, ResponseType) ->
+ Options = [],
+ HttpOptions = [{timeout, ?REQUEST_TIMEOUT}],
+ case httpc:request(get, {Url, []}, HttpOptions, Options) of
+ {ok, {{_, Code, _}, Head, Body}} when Code >= 200, Code =< 299 ->
+ decode_response(Head, Body, ResponseType);
+ Error ->
+ failed_http_request(Error, Url)
+ end.
+
+-spec prepare_post_request(url(), jose_jwk:key(), jiffy:json_value(),
+ nonce(), handle_resp_fun()) -> {ok, _, nonce()} | {error, _}.
+prepare_post_request(Url, PrivateKey, EJson, Nonce, HandleRespFun) ->
+ prepare_post_request(Url, PrivateKey, EJson, Nonce, HandleRespFun, "application/jose+json").
+
+-spec prepare_post_request(url(), jose_jwk:key(), jiffy:json_value(),
+ nonce(), handle_resp_fun(), string()) -> {ok, _, nonce()} | {error, _}.
+prepare_post_request(Url, PrivateKey, EJson, Nonce, HandleRespFun, ResponseType) ->
+ case encode(EJson) of
+ {ok, ReqBody} ->
+ FinalBody = sign_encode_json_jose(PrivateKey, ReqBody, Nonce),
+ case make_post_request(Url, FinalBody, ResponseType) of
+ {ok, Head, Return} ->
+ HandleRespFun({ok, Head, Return});
+ Error ->
+ Error
+ end;
+ {error, Reason} ->
+ ?ERROR_MSG("Error: ~p when encoding: ~p", [Reason, EJson]),
+ {error, Reason}
+ end.
+
+-spec prepare_get_request(url(), handle_resp_fun()) ->
+ {ok, _, nonce()} | {error, _}.
+prepare_get_request(Url, HandleRespFun) ->
+ prepare_get_request(Url, HandleRespFun, "application/jose+json").
+
+-spec prepare_get_request(url(), handle_resp_fun(), string()) ->
+ {ok, _, nonce()} | {error, _}.
+prepare_get_request(Url, HandleRespFun, ResponseType) ->
+ case make_get_request(Url, ResponseType) of
+ {ok, Head, Return} ->
+ HandleRespFun({ok, Head, Return});
+ Error ->
+ Error
+ end.
+
+
+%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%
+%%
+%% Jose Json Functions
+%%
+%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%
+
+-spec sign_json_jose(jose_jwk:key(), bitstring(), nonce()) -> {_, jws()}.
+sign_json_jose(Key, Json, Nonce) ->
+ PubKey = ejabberd_acme:to_public(Key),
+ {_, BinaryPubKey} = jose_jwk:to_binary(PubKey),
+ PubKeyJson = jiffy:decode(BinaryPubKey),
+ %% TODO: Ensure this works for all cases
+ AlgMap = jose_jwk:signer(Key),
+ JwsMap =
+ #{ <<"jwk">> => PubKeyJson,
+ %% <<"b64">> => true,
+ <<"nonce">> => list_to_bitstring(Nonce)
+ },
+ JwsObj0 = maps:merge(JwsMap, AlgMap),
+ JwsObj = jose_jws:from(JwsObj0),
+ jose_jws:sign(Key, Json, JwsObj).
+
+-spec sign_encode_json_jose(jose_jwk:key(), bitstring(), nonce()) -> bitstring().
+sign_encode_json_jose(Key, Json, Nonce) ->
+ {_, Signed} = sign_json_jose(Key, Json, Nonce),
+ %% This depends on jose library, so we can consider it safe
+ jiffy:encode(Signed).
+
+
+%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%
+%%
+%% Useful funs
+%%
+%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%
+
+-spec get_nonce(proplist()) -> nonce() | 'none'.
+get_nonce(Head) ->
+ case proplists:lookup("replay-nonce", Head) of
+ {"replay-nonce", Nonce} -> Nonce;
+ none -> none
+ end.
+
+-spec get_location(proplist()) -> url() | 'none'.
+get_location(Head) ->
+ case proplists:lookup("location", Head) of
+ {"location", Location} -> Location;
+ none -> none
+ end.
+
+%% Very bad way to extract this
+%% TODO: Find a better way
+-spec get_tos(proplist()) -> url() | 'none'.
+get_tos(Head) ->
+ try
+ [{_, Link}] = [{K, V} || {K, V} <- Head,
+ K =:= "link" andalso
+ lists:suffix("\"terms-of-service\"", V)],
+ [Link1, _] = string:tokens(Link, ";"),
+ Link2 = string:strip(Link1, left, $<),
+ string:strip(Link2, right, $>)
+ catch
+ _:_ ->
+ none
+ end.
+
+decode_response(Head, Body, "application/pkix-cert") ->
+ {ok, Head, Body};
+decode_response(Head, Body, "application/jose+json") ->
+ case decode(Body) of
+ {ok, Return} ->
+ {ok, Head, Return};
+ {error, Reason} ->
+ ?ERROR_MSG("Problem decoding: ~s", [Body]),
+ {error, Reason}
+ end.
+
+encode(EJson) ->
+ try
+ {ok, jiffy:encode(EJson)}
+ catch
+ _:Reason ->
+ {error, Reason}
+ end.
+
+decode(Json) ->
+ try
+ {Result} = jiffy:decode(Json),
+ {ok, Result}
+ catch
+ _:Reason ->
+ {error, Reason}
+ end.
+
+%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%
+%%
+%% Handle Failed HTTP Requests
+%%
+%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%
+
+-spec failed_http_request({ok, _} | {error, _}, url()) -> {error, _}.
+failed_http_request({ok, {{_, Code, Reason}, _Head, Body}}, Url) ->
+ ?ERROR_MSG("Got unexpected status code from <~s>: ~B, Body: ~s",
+ [Url, Code, Body]),
+ throw({error, {unexpected_code, Code, Reason}});
+failed_http_request({error, Reason}, Url) ->
+ ?ERROR_MSG("Error making a request to <~s>: ~p",
+ [Url, Reason]),
+ throw({error, Reason}).
diff --git a/src/ejabberd_admin.erl b/src/ejabberd_admin.erl
index 8b4af2857..368c7fe53 100644
--- a/src/ejabberd_admin.erl
+++ b/src/ejabberd_admin.erl
@@ -44,6 +44,11 @@
registered_users/1,
%% Migration jabberd1.4
import_file/1, import_dir/1,
+ %% Acme
+ get_certificate/1,
+ renew_certificate/0,
+ list_certificates/1,
+ revoke_certificate/1,
%% Purge DB
delete_expired_messages/0, delete_old_messages/1,
%% Mnesia
@@ -104,7 +109,7 @@ get_commands_spec() ->
module = ?MODULE, function = status,
result_desc = "Result tuple",
result_example = {ok, <<"The node ejabberd@localhost is started with status: started"
- "ejabberd X.X is running in that node">>},
+ "ejabberd X.X is running in that node">>},
args = [], result = {res, restuple}},
#ejabberd_commands{name = stop, tags = [server],
desc = "Stop ejabberd gracefully",
@@ -126,9 +131,9 @@ get_commands_spec() ->
#ejabberd_commands{name = stop_kindly, tags = [server],
desc = "Inform users and rooms, wait, and stop the server",
longdesc = "Provide the delay in seconds, and the "
- "announcement quoted, for example: \n"
- "ejabberdctl stop_kindly 60 "
- "\\\"The server will stop in one minute.\\\"",
+ "announcement quoted, for example: \n"
+ "ejabberdctl stop_kindly 60 "
+ "\\\"The server will stop in one minute.\\\"",
module = ?MODULE, function = stop_kindly,
args_desc = ["Seconds to wait", "Announcement to send, with quotes"],
args_example = [60, <<"Server will stop now.">>],
@@ -192,7 +197,7 @@ get_commands_spec() ->
result_example = [<<"user1">>, <<"user2">>],
args = [{host, binary}],
result = {users, {list, {username, string}}}},
- #ejabberd_commands{name = registered_vhosts, tags = [server],
+ #ejabberd_commands{name = registered_vhosts, tags = [server],
desc = "List all registered vhosts in SERVER",
module = ?MODULE, function = registered_vhosts,
result_desc = "List of available vhosts",
@@ -215,7 +220,7 @@ get_commands_spec() ->
#ejabberd_commands{name = leave_cluster, tags = [cluster],
desc = "Remove and shutdown Node from the running cluster",
longdesc = "This command can be run from any running node of the cluster, "
- "even the node to be removed.",
+ "even the node to be removed.",
module = ?MODULE, function = leave_cluster,
args_desc = ["Nodename of the node to kick from the cluster"],
args_example = [<<"ejabberd1@machine8">>],
@@ -242,6 +247,30 @@ get_commands_spec() ->
args_example = ["/var/lib/ejabberd/jabberd14/"],
args = [{file, string}],
result = {res, restuple}},
+ #ejabberd_commands{name = get_certificate, tags = [acme],
+ desc = "Gets a certificate for all or the specified domains {all|domain1;domain2;...}.",
+ module = ?MODULE, function = get_certificate,
+ args_desc = ["Domains for which to acquire a certificate"],
+ args_example = ["all | www.example.com;www.example1.net"],
+ args = [{domains, string}],
+ result = {certificates, string}},
+ #ejabberd_commands{name = renew_certificate, tags = [acme],
+ desc = "Renews all certificates that are close to expiring",
+ module = ?MODULE, function = renew_certificate,
+ args = [],
+ result = {certificates, string}},
+ #ejabberd_commands{name = list_certificates, tags = [acme],
+ desc = "Lists all curently handled certificates and their respective domains in {plain|verbose} format",
+ module = ?MODULE, function = list_certificates,
+ args_desc = ["Whether to print the whole certificate or just some metadata. Possible values: plain | verbose"],
+ args = [{option, string}],
+ result = {certificates, {list, {certificate, string}}}},
+ #ejabberd_commands{name = revoke_certificate, tags = [acme],
+ desc = "Revokes the selected certificate",
+ module = ?MODULE, function = revoke_certificate,
+ args_desc = ["The domain or file (in pem format) of the certificate in question {domain:Domain | file:File}"],
+ args = [{domain_or_file, string}],
+ result = {res, restuple}},
#ejabberd_commands{name = import_piefxis, tags = [mnesia],
desc = "Import users data from a PIEFXIS file (XEP-0227)",
@@ -321,9 +350,9 @@ get_commands_spec() ->
desc = "Change the erlang node name in a backup file",
module = ?MODULE, function = mnesia_change_nodename,
args_desc = ["Name of the old erlang node", "Name of the new node",
- "Path to old backup file", "Path to the new backup file"],
+ "Path to old backup file", "Path to the new backup file"],
args_example = ["ejabberd@machine1", "ejabberd@machine2",
- "/var/lib/ejabberd/old.backup", "/var/lib/ejabberd/new.backup"],
+ "/var/lib/ejabberd/old.backup", "/var/lib/ejabberd/new.backup"],
args = [{oldnodename, string}, {newnodename, string},
{oldbackup, string}, {newbackup, string}],
result = {res, restuple}},
@@ -421,7 +450,7 @@ stop_kindly(DelaySeconds, AnnouncementTextString) ->
{"Stopping ejabberd", application, stop, [ejabberd]},
{"Stopping Mnesia", mnesia, stop, []},
{"Stopping Erlang node", init, stop, []}
- ],
+ ],
NumberLast = length(Steps),
TimestampStart = calendar:datetime_to_gregorian_seconds({date(), time()}),
lists:foldl(
@@ -469,8 +498,8 @@ update_module(ModuleNameBin) when is_binary(ModuleNameBin) ->
update_module(ModuleNameString) ->
ModuleName = list_to_atom(ModuleNameString),
case ejabberd_update:update([ModuleName]) of
- {ok, _Res} -> {ok, []};
- {error, Reason} -> {error, Reason}
+ {ok, _Res} -> {ok, []};
+ {error, Reason} -> {error, Reason}
end.
%%%
@@ -500,7 +529,7 @@ registered_users(Host) ->
lists:map(fun({U, _S}) -> U end, SUsers).
registered_vhosts() ->
- ?MYHOSTS.
+ ?MYHOSTS.
reload_config() ->
ejabberd_config:reload_file().
@@ -542,6 +571,38 @@ import_dir(Path) ->
{cannot_import_dir, String}
end.
+%%%
+%%% Acme
+%%%
+
+get_certificate(Domains) ->
+ case ejabberd_acme:is_valid_domain_opt(Domains) of
+ true ->
+ ejabberd_acme:get_certificates(Domains);
+ false ->
+ io_lib:format("Invalid domains: ~p", [Domains])
+ end.
+
+renew_certificate() ->
+ ejabberd_acme:renew_certificates().
+
+list_certificates(Verbose) ->
+ case ejabberd_acme:is_valid_verbose_opt(Verbose) of
+ true ->
+ ejabberd_acme:list_certificates(Verbose);
+ false ->
+ String = io_lib:format("Invalid verbose option: ~p", [Verbose]),
+ {invalid_option, String}
+ end.
+
+revoke_certificate(DomainOrFile) ->
+ case ejabberd_acme:is_valid_revoke_cert(DomainOrFile) of
+ true ->
+ ejabberd_acme:revoke_certificate(DomainOrFile);
+ false ->
+ String = io_lib:format("Bad argument: ~s", [DomainOrFile]),
+ {invalid_argument, String}
+ end.
%%%
%%% Purge DB
diff --git a/src/ejabberd_http.erl b/src/ejabberd_http.erl
index 30cce1952..0bc0d8fc4 100644
--- a/src/ejabberd_http.erl
+++ b/src/ejabberd_http.erl
@@ -465,7 +465,9 @@ process_request(#state{request_method = Method,
opts = Options,
headers = RequestHeaders,
ip = IP},
- Res = case process(RequestHandlers, Request, Socket, SockMod, Trail) of
+ RequestHandlers1 = ejabberd_hooks:run_fold(
+ http_request_handlers, RequestHandlers, [Host, Request]),
+ Res = case process(RequestHandlers1, Request, Socket, SockMod, Trail) of
El when is_record(El, xmlel) ->
make_xhtml_output(State, 200, CustomHeaders, El);
{Status, Headers, El}
diff --git a/src/ejabberd_pkix.erl b/src/ejabberd_pkix.erl
index 7efe7b427..68b8226c8 100644
--- a/src/ejabberd_pkix.erl
+++ b/src/ejabberd_pkix.erl
@@ -301,7 +301,14 @@ add_certfiles(Host, State) ->
NewAccState
end
end, State, certfiles_from_config_options()),
- if State /= State1 ->
+ State2 = case ejabberd_acme:certificate_exists(Host) of
+ {true, Path} ->
+ {_, State3} = add_certfile(Path, State1),
+ State3;
+ false ->
+ State1
+ end,
+ if State /= State2 ->
case build_chain_and_check(State1) of
ok -> {ok, State1};
{error, _} = Err -> Err