aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--ejabberd.yml.example13
-rw-r--r--include/ejabberd_acme.hrl34
-rw-r--r--rebar.config3
-rwxr-xr-xrun_acme.sh9
-rw-r--r--src/acme_challenge.erl137
-rw-r--r--src/acme_experimental.erl607
-rw-r--r--src/ejabberd_acme.erl881
-rw-r--r--src/ejabberd_acme_comm.erl393
-rw-r--r--src/ejabberd_admin.erl138
9 files changed, 2165 insertions, 50 deletions
diff --git a/ejabberd.yml.example b/ejabberd.yml.example
index 3f0e8d1c6..85754e1bb 100644
--- a/ejabberd.yml.example
+++ b/ejabberd.yml.example
@@ -161,6 +161,7 @@ listen:
"/ws": ejabberd_http_ws
"/bosh": mod_bosh
"/api": mod_http_api
+ "/.well-known": acme_challenge
## "/pub/archive": mod_http_fileserver
web_admin: true
## register: true
@@ -659,6 +660,18 @@ language: "en"
##
## captcha_limit: 5
+###. ====
+###' ACME
+
+##
+## Must contain a contact and a directory that the Http Challenges can be solved at
+##
+acme:
+ contact: "mailto:cert-admin-ejabberd@example.com"
+ http_dir: "/home/konstantinos/Desktop/Programming/test-server-for-acme/"
+
+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..8e8e558a0
--- /dev/null
+++ b/include/ejabberd_acme.hrl
@@ -0,0 +1,34 @@
+
+-record(challenge, {
+ type = <<"http-01">> :: bitstring(),
+ status = pending :: pending | valid | invalid,
+ uri = "" :: url(),
+ token = <<"">> :: bitstring()
+ }).
+
+-record(data_acc, {
+ id :: list(),
+ key :: jose_jwk:key()
+ }).
+
+-record(data_cert, {
+ domain :: list(),
+ pem :: jose_jwk:key(),
+ path :: file:filename()
+ }).
+
+
+
+
+-type nonce() :: string().
+-type url() :: string().
+-type proplist() :: [{_, _}].
+-type dirs() :: #{string() => url()}.
+-type jws() :: map().
+-type handle_resp_fun() :: fun(({ok, proplist(), proplist()}) -> {ok, _, nonce()}).
+
+-type acme_challenge() :: #challenge{}.
+
+-type account_opt() :: string().
+
+-type pem_certificate() :: bitstring().
diff --git a/rebar.config b/rebar.config
index 160185511..d24236cee 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", {branch, "master"}}},
{if_var_true, stun, {stun, ".*", {git, "https://github.com/processone/stun", {tag, "1.0.14"}}}},
{if_var_true, sip, {esip, ".*", {git, "https://github.com/processone/esip", {tag, "1.0.15"}}}},
{if_var_true, mysql, {p1_mysql, ".*", {git, "https://github.com/processone/p1_mysql",
@@ -94,6 +95,8 @@
{if_have_fun, {crypto, strong_rand_bytes, 1}, {d, 'STRONG_RAND_BYTES'}},
{if_have_fun, {gb_sets, iterator_from, 2}, {d, 'GB_SETS_ITERATOR_FROM'}},
{if_have_fun, {public_key, short_name_hash, 1}, {d, 'SHORT_NAME_HASH'}},
+ %% {if_have_fun, {public_key, generate_key, 1}, {d, 'GENERATE_RSA_KEY'}},
+ {if_version_above, "19", {d, 'GENERATE_RSA_KEY'}},
{if_var_true, hipe, native},
{src_dirs, [asn1, src,
{if_var_true, tools, tools},
diff --git a/run_acme.sh b/run_acme.sh
new file mode 100755
index 000000000..c4c7df4d9
--- /dev/null
+++ b/run_acme.sh
@@ -0,0 +1,9 @@
+#!/bin/bash
+
+erl -pa ebin \
+deps/jiffy/ebin \
+deps/fast_tls/ebin \
+deps/jose/ebin \
+deps/base64url/ebin \
+deps/xmpp/ebin \
+-noshell -s acme_experimental scenario -s erlang halt \ No newline at end of file
diff --git a/src/acme_challenge.erl b/src/acme_challenge.erl
new file mode 100644
index 000000000..081e10429
--- /dev/null
+++ b/src/acme_challenge.erl
@@ -0,0 +1,137 @@
+-module(acme_challenge).
+
+-export ([key_authorization/2,
+ solve_challenge/3,
+
+ process/2
+ ]).
+%% 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").
+
+%% TODO: Maybe validate request here??
+process(LocalPath, Request) ->
+ Result = ets_get_key_authorization(LocalPath),
+ ?INFO_MSG("Trying to serve: ~p at: ~p", [Request, LocalPath]),
+ ?INFO_MSG("Http Response: ~p", [Result]),
+ {200,
+ [{<<"Content-Type">>, <<"text/plain">>}],
+ Result}.
+
+
+-spec key_authorization(bitstring(), jose_jwk:key()) -> bitstring().
+key_authorization(Token, Key) ->
+ Thumbprint = jose_jwk:thumbprint(Key),
+ %% ?INFO_MSG("Thumbprint: ~p~n", [Thumbprint]),
+ 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) ->
+ ?INFO_MSG("Challenge: ~p~n", [Challenge]).
+
+
+save_key_authorization(Chal, Tkn, KeyAuthz, HttpDir) ->
+ FileLocation = HttpDir ++ "/.well-known/acme-challenge/" ++ bitstring_to_list(Tkn),
+ case file:write_file(FileLocation, KeyAuthz) of
+ ok ->
+ {ok, Chal#challenge.uri, KeyAuthz};
+ {error, Reason} = Err ->
+ ?ERROR_MSG("Error writing to file: ~s with reason: ~p~n", [FileLocation, Reason]),
+ Err
+ end.
+
+-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:take(Tab, Key) of
+ [{Key, KeyAuthz}] ->
+ 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/acme_experimental.erl b/src/acme_experimental.erl
new file mode 100644
index 000000000..08fdc6ff4
--- /dev/null
+++ b/src/acme_experimental.erl
@@ -0,0 +1,607 @@
+-module(acme_experimental).
+
+-behaviour(gen_server).
+
+%% API
+-export([ start/0
+ , stop/1
+ %% I tried to follow the naming convention found in the acme spec
+ , directory/2
+ , new_nonce/2
+ %% Account
+ , new_reg/2
+ , update_account/2
+ , account_info/2 %% TODO: Maybe change to get_account
+ , account_key_change/2
+ , deactivate_account/2
+ %% Orders/Certificates
+ , new_cert/2
+ , new_authz/2
+ , get_certificate/2
+ , get_authz/2
+ , complete_challenge/2
+ , deactivate_authz/2
+ , revoke_cert/2]).
+
+-export([init/1, handle_call/3, handle_cast/2,
+ handle_info/2, terminate/2, code_change/3]).
+
+-export([scenario/0]).
+
+-include("ejabberd.hrl").
+-include("logger.hrl").
+
+-include("xmpp.hrl").
+-include_lib("public_key/include/public_key.hrl").
+
+% -define(CA_URL, "https://acme-v01.api.letsencrypt.org").
+
+
+
+-define(DEFAULT_DIRECTORY, ?CA_URL ++ "/directory").
+-define(DEFAULT_NEW_NONCE, ?CA_URL ++ "/acme/new_nonce").
+
+-define(DEFAULT_KEY_FILE, "private_key_temporary").
+
+
+
+
+-define(LOCAL_TESTING, true).
+
+-ifdef(LOCAL_TESTING).
+-define(CA_URL, "http://localhost:4000").
+-define(DEFAULT_ACCOUNT, "2").
+-define(DEFAULT_TOS, <<"http://boulder:4000/terms/v1">>).
+-define(DEFAULT_AUTHZ,
+ <<"http://localhost:4000/acme/authz/XDAfMW6xBdRogD2-VIfTxlzo4RTlaE2U6x0yrwxnXlw">>).
+-else.
+-define(CA_URL, "https://acme-staging.api.letsencrypt.org").
+-define(DEFAULT_ACCOUNT, "2273801").
+-define(DEFAULT_TOS, <<"https://letsencrypt.org/documents/LE-SA-v1.1.1-August-1-2016.pdf">>).
+-define(DEFAULT_AUTHZ, <<"">>).
+-endif.
+
+-record(state, {
+ ca_url = ?CA_URL :: list(),
+ dir_url = ?DEFAULT_DIRECTORY :: list(),
+ dirs = maps:new(),
+ nonce = "",
+ account = none
+ }).
+
+%% This will be initially just be filled with stub functions
+
+start() ->
+ gen_server:start(?MODULE, [], []).
+
+stop(Pid) ->
+ gen_server:stop(Pid).
+
+%% Stub functions
+directory(Pid, Options) ->
+ gen_server:call(Pid, ?FUNCTION_NAME).
+
+new_nonce(Pid, Options) ->
+ gen_server:call(Pid, ?FUNCTION_NAME).
+
+new_reg(Pid, Options) ->
+ gen_server:call(Pid, ?FUNCTION_NAME).
+
+update_account(Pid, AccountId) ->
+ %% TODO: This has to have more info ofcourse
+ gen_server:call(Pid, {?FUNCTION_NAME, AccountId}).
+
+account_info(Pid, AccountId) ->
+ gen_server:call(Pid, {?FUNCTION_NAME, AccountId}).
+
+account_key_change(Pid, Options) ->
+ ok.
+
+deactivate_account(Pid, Options) ->
+ ok.
+
+new_cert(Pid, Options) ->
+ gen_server:call(Pid, ?FUNCTION_NAME).
+
+new_authz(Pid, Options) ->
+ gen_server:call(Pid, ?FUNCTION_NAME).
+
+get_certificate(Pid, Options) ->
+ ok.
+
+get_authz(Pid, Options) ->
+ gen_server:call(Pid, ?FUNCTION_NAME).
+
+complete_challenge(Pid, Options) ->
+ gen_server:call(Pid, {?FUNCTION_NAME, Options}).
+
+deactivate_authz(Pid, Options) ->
+ ok.
+
+revoke_cert(Pid, Options) ->
+ ok.
+
+
+
+%% GEN SERVER
+
+init([]) ->
+ %% TODO: Not the correct way of doing it
+ ok = application:start(inets),
+ ok = application:start(crypto),
+ ok = application:start(asn1),
+ ok = application:start(public_key),
+ ok = application:start(ssl),
+
+ ok = application:start(base64url),
+ ok = application:start(jose),
+
+ {ok, #state{}}.
+
+handle_call(directory, _From, S = #state{dir_url=Url, dirs=Dirs}) ->
+ %% Make the get request
+ {ok, {_Status, Head, Body}} = httpc:request(get, {Url, []}, [], []),
+
+ %% Decode the json string
+ {Directories} = jiffy:decode(Body),
+ StrDirectories = [{bitstring_to_list(X), bitstring_to_list(Y)} ||
+ {X,Y} <- Directories],
+
+ % Find and save the replay nonce
+ % io:format("Directory Head Response: ~p~n", [Head]),
+ Nonce = get_nonce(Head),
+
+ %% Update the directories in state
+ NewDirs = maps:from_list(StrDirectories),
+ % io:format("New directories: ~p~n", [NewDirs]),
+
+ {reply, {ok, {Directories}}, S#state{dirs = NewDirs, nonce = Nonce}};
+handle_call(new_nonce, _From, S = #state{dirs=Dirs}) ->
+ %% Get url from all directories
+ #{"new_nonce" := Url} = Dirs,
+ {ok, {Status, Head, []}} =
+ httpc:request(head, {Url, []}, [], []),
+ {reply, {ok, {Status, Head}}, S};
+handle_call(new_reg, _From, S = #state{ca_url = Ca, dirs=Dirs, nonce = Nonce}) ->
+ %% Get url from all directories
+ #{"new-reg" := Url} = Dirs,
+
+ %% Make the request body
+ ReqBody = jiffy:encode({
+ [ { <<"contact">>, [<<"mailto:cert-admin@example.com">>]}
+ , { <<"resource">>, <<"new-reg">>}
+ ]}),
+
+ %% Generate a key for the first time use
+ Key = generate_key(),
+
+ %% Write the key to a file
+ jose_jwk:to_file(?DEFAULT_KEY_FILE, Key),
+
+ %% Jose
+ {_, SignedBody} = sign_a_json_object_using_jose(Key, ReqBody, Url, Nonce),
+ % io:format("Signed Body: ~p~n", [SignedBody]),
+
+ %% Encode the Signed body with jiffy
+ FinalBody = jiffy:encode(SignedBody),
+
+ %% Post request
+ {ok, {Status, Head, Body}} =
+ httpc:request(post, {Url, [], "application/jose+json", FinalBody}, [], []),
+
+ %% Get and save the new nonce
+ NewNonce = get_nonce(Head),
+
+ {reply, {ok, {Status, Head, Body}}, S#state{nonce=NewNonce}};
+handle_call({account_info, AccountId}, _From, S = #state{ca_url = Ca, dirs=Dirs, nonce = Nonce}) ->
+ %% Get url from accountId
+ Url = Ca ++ "/acme/reg/" ++ AccountId,
+
+ %% Make the request body
+ ReqBody = jiffy:encode({[
+ { <<"resource">>, <<"reg">>}
+ ]}),
+
+ %% Get the key from a file
+ Key = jose_jwk:from_file(?DEFAULT_KEY_FILE),
+
+ %% Jose
+ {_, SignedBody} = sign_a_json_object_using_jose(Key, ReqBody, Url, Nonce),
+ % io:format("Signed Body: ~p~n", [SignedBody]),
+
+ %% Encode the Signed body with jiffy
+ FinalBody = jiffy:encode(SignedBody),
+
+ %% Post request
+ {ok, {Status, Head, Body}} =
+ httpc:request(post, {Url, [], "application/jose+json", FinalBody}, [], []),
+
+ % Get and save the new nonce
+ NewNonce = get_nonce(Head),
+
+ {reply, {ok, {Status, Head, Body}}, S#state{nonce=NewNonce}};
+handle_call({update_account, AccountId}, _From, S = #state{ca_url = Ca, dirs=Dirs, nonce = Nonce}) ->
+ %% Get url from accountId
+ Url = Ca ++ "/acme/reg/" ++ AccountId,
+
+ %% Make the request body
+ ReqBody = jiffy:encode({[
+ { <<"resource">>, <<"reg">>},
+ { <<"agreement">>, ?DEFAULT_TOS}
+ ]}),
+
+ %% Get the key from a file
+ Key = jose_jwk:from_file(?DEFAULT_KEY_FILE),
+
+ %% Jose
+ {_, SignedBody} = sign_a_json_object_using_jose(Key, ReqBody, Url, Nonce),
+ % io:format("Signed Body: ~p~n", [SignedBody]),
+
+ %% Encode the Signed body with jiffy
+ FinalBody = jiffy:encode(SignedBody),
+
+ %% Post request
+ {ok, {Status, Head, Body}} =
+ httpc:request(post, {Url, [], "application/jose+json", FinalBody}, [], []),
+
+ % Get and save the new nonce
+ NewNonce = get_nonce(Head),
+
+ {reply, {ok, {Status, Head, Body}}, S#state{nonce=NewNonce}};
+handle_call(new_cert, _From, S = #state{ca_url = Ca, dirs=Dirs, nonce = Nonce}) ->
+ %% Get url from all directories
+ #{"new-cert" := Url} = Dirs,
+
+ MyCSR = make_csr(),
+ % file:write_file("myCSR.der", CSR),
+ % {ok, CSR} = file:read_file("CSR.der"),
+ % io:format("CSR: ~p~nMy Encoded CSR: ~p~nCorrect Encoded CSR: ~p~n",
+ % [ public_key:der_decode('CertificationRequest', CSR)
+ % , MyCSR
+ % , CSR]),
+
+ CSRbase64 = base64url:encode(MyCSR),
+
+ % io:format("CSR base64: ~p~n", [CSRbase64]),
+ {MegS, Sec, MicS} = erlang:timestamp(),
+ NotBefore = xmpp_util:encode_timestamp({MegS-1, Sec, MicS}),
+ NotAfter = xmpp_util:encode_timestamp({MegS+1, Sec, MicS}),
+
+ %% Make the request body
+ ReqBody = jiffy:encode({[
+ {<<"resource">>, <<"new-cert">>},
+ {<<"csr">>, CSRbase64},
+ {<<"notBefore">>, NotBefore},
+ {<<"NotAfter">>, NotAfter}
+ ]}),
+ %% Get the key from a file
+ Key = jose_jwk:from_file(?DEFAULT_KEY_FILE),
+
+ %% Jose
+ {_, SignedBody} = sign_a_json_object_using_jose(Key, ReqBody, Url, Nonce),
+ % io:format("Signed Body: ~p~n", [SignedBody]),
+
+ %% Encode the Signed body with jiffy
+ FinalBody = jiffy:encode(SignedBody),
+
+ %% Post request
+ {ok, {Status, Head, Body}} =
+ httpc:request(post, {Url, [], "application/pkix-cert", FinalBody}, [], []),
+
+ % Get and save the new nonce
+ NewNonce = get_nonce(Head),
+
+ {reply, {ok, {Status, Head, Body}}, S#state{nonce=NewNonce}};
+handle_call(new_authz, _From, S = #state{ca_url = Ca, dirs=Dirs, nonce = Nonce}) ->
+ %% Get url from all directories
+ #{"new-authz" := Url} = Dirs,
+
+ %% Make the request body
+ ReqBody = jiffy:encode({
+ [ { <<"identifier">>, {
+ [ {<<"type">>, <<"dns">>}
+ , {<<"value">>, <<"my-acme-test-ejabberd.com">>}
+ ] }}
+ , {<<"existing">>, <<"accept">>}
+ , { <<"resource">>, <<"new-authz">>}
+ ] }),
+
+ %% Get the key from a file
+ Key = jose_jwk:from_file(?DEFAULT_KEY_FILE),
+
+ %% Jose
+ {_, SignedBody} = sign_a_json_object_using_jose(Key, ReqBody, Url, Nonce),
+ % io:format("Signed Body: ~p~n", [SignedBody]),
+
+ %% Encode the Signed body with jiffy
+ FinalBody = jiffy:encode(SignedBody),
+
+ %% Post request
+ {ok, {Status, Head, Body}} =
+ httpc:request(post, {Url, [], "application/jose+json", FinalBody}, [], []),
+
+ % Get and save the new nonce
+ NewNonce = get_nonce(Head),
+
+ {reply, {ok, {Status, Head, Body}}, S#state{nonce=NewNonce}};
+handle_call(get_authz, _From, S = #state{ca_url = Ca, dirs=Dirs, nonce = Nonce}) ->
+ %% Get url from all directories
+ Url = bitstring_to_list(?DEFAULT_AUTHZ),
+
+ %% Post request
+ {ok, {Status, Head, Body}} =
+ % httpc:request(post, {Url, [], "application/jose+json", FinalBody}, [], []),
+ httpc:request(Url),
+
+ % Get and save the new nonce
+ NewNonce = get_nonce(Head),
+
+ {reply, {ok, {Status, Head, Body}}, S#state{nonce=NewNonce}};
+handle_call({complete_challenge, [Solution]}, _From, S = #state{ca_url = Ca, dirs=Dirs, nonce = Nonce}) ->
+ %% Get url from all directories
+ {ChallengeType, BitUrl, KeyAuthz} = Solution,
+ Url = bitstring_to_list(BitUrl),
+
+ %% Make the request body
+ ReqBody = jiffy:encode({
+ [ { <<"keyAuthorization">>, KeyAuthz}
+ , {<<"type">>, ChallengeType}
+ , { <<"resource">>, <<"challenge">>}
+ ] }),
+
+ %% Get the key from a file
+ Key = jose_jwk:from_file(?DEFAULT_KEY_FILE),
+
+ %% Jose
+ {_, SignedBody} = sign_a_json_object_using_jose(Key, ReqBody, Url, Nonce),
+ % io:format("Signed Body: ~p~n", [SignedBody]),
+
+ %% Encode the Signed body with jiffy
+ FinalBody = jiffy:encode(SignedBody),
+
+ %% Post request
+ {ok, {Status, Head, Body}} =
+ httpc:request(post, {Url, [], "application/jose+json", FinalBody}, [], []),
+
+ % Get and save the new nonce
+ NewNonce = get_nonce(Head),
+
+ {reply, {ok, {Status, Head, Body}}, S#state{nonce=NewNonce}};
+handle_call(stop, _From, State) ->
+ {stop, normal, ok, State}.
+
+handle_cast(Msg, State) ->
+ ?WARNING_MSG("unexpected cast: ~p", [Msg]),
+ {noreply, State}.
+
+handle_info(_Info, State) ->
+ {noreply, State}.
+
+terminate(_Reason, _State) ->
+ ok.
+
+code_change(_OldVsn, State, _Extra) ->
+ {ok, State}.
+
+%% Util functions
+
+final_url(Urls) ->
+ Joined = lists:join("/", Urls),
+ lists:flatten(Joined).
+
+get_nonce(Head) ->
+ {"replay-nonce", Nonce} = proplists:lookup("replay-nonce", Head),
+ Nonce.
+
+get_challenges({Body}) ->
+ {<<"challenges">>, Challenges} = proplists:lookup(<<"challenges">>, Body),
+ Challenges.
+
+
+%% Test
+
+generate_key() ->
+ % Generate a key for now
+ Key = jose_jwk:generate_key({ec, secp256r1}),
+ io:format("Key: ~p~n", [Key]),
+ Key.
+
+sign_a_json_object_using_jose(Key, Json, Url, Nonce) ->
+ % Generate a public key
+ PubKey = jose_jwk:to_public(Key),
+ % io:format("Public Key: ~p~n", [PubKey]),
+ {_, BinaryPubKey} = jose_jwk:to_binary(PubKey),
+ % io:format("Public Key: ~p~n", [BinaryPubKey]),
+ PubKeyJson = jiffy:decode(BinaryPubKey),
+ % io:format("Public Key: ~p~n", [PubKeyJson]),
+
+ % Jws object containing the algorithm
+ JwsObj = jose_jws:from(
+ #{ <<"alg">> => <<"ES256">>
+ %% Im not sure if it is needed
+ % , <<"b64">> => true
+ , <<"jwk">> => PubKeyJson
+ , <<"nonce">> => list_to_bitstring(Nonce)
+ }),
+ % io:format("Jws: ~p~n", [JwsObj]),
+
+ %% Signed Message
+ Signed = jose_jws:sign(Key, Json, JwsObj),
+ % io:format("Signed: ~p~n", [Signed]),
+
+ %% Peek protected
+ Protected = jose_jws:peek_protected(Signed),
+ % io:format("Protected: ~p~n", [jiffy:decode(Protected)]),
+
+ %% Peek Payload
+ Payload = jose_jws:peek_payload(Signed),
+ io:format("Payload: ~p~n", [jiffy:decode(Payload)]),
+
+ %% Verify
+ % {true, _} = jose_jws:verify(Key, Signed),
+ % io:format("Verify: ~p~n", [jose_jws:verify(Key, Signed)]),
+
+ Signed.
+
+make_csr() ->
+
+ SigningKey = jose_jwk:from_pem_file("csr_signing_private_key.key"),
+ {_, PrivateKey} = jose_jwk:to_key(SigningKey),
+ % io:format("PrivateKey: ~p~n", [PrivateKey]),
+
+ PubKey = jose_jwk:to_public(SigningKey),
+ % io:format("Public Key: ~p~n", [PubKey]),
+
+ {_, BinaryPubKey} = jose_jwk:to_binary(PubKey),
+ % io:format("Public Key: ~p~n", [BinaryPubKey]),
+
+ {_, RawPubKey} = jose_jwk:to_key(PubKey),
+ % io:format("Raw Public Key: ~p~n", [RawPubKey]),
+ {{_, RawBinPubKey}, _} = RawPubKey,
+ % io:format("Encoded Raw Public Key: ~p~n", [RawBinPubKey]),
+
+ %% TODO: Understand how to extract the information below from the key struct
+ AlgoID = #'CertificationRequestInfo_subjectPKInfo_algorithm'{
+ algorithm = {1,2,840,10045,2,1}, %% Very dirty
+ parameters = {asn1_OPENTYPE,<<6,8,42,134,72,206,61,3,1,7>>}
+ },
+ SubPKInfo = #'CertificationRequestInfo_subjectPKInfo'{
+ algorithm = AlgoID, %% Very dirty
+ subjectPublicKey = RawBinPubKey %% public_key:der_encode('ECPoint', RawPubKey)
+ },
+
+ CommonName = #'AttributeTypeAndValue'{
+ type = {2,5,4,3},
+ % value = list_to_bitstring([12,25] ++ "my-acme-test-ejabberd.com")
+ value = length_bitstring(<<"my-acme-test-ejabberd.com">>)
+ },
+ CountryName = #'AttributeTypeAndValue'{
+ type = {2,5,4,6},
+ value = length_bitstring(<<"US">>)
+ },
+ StateOrProvinceName = #'AttributeTypeAndValue'{
+ type = {2,5,4,8},
+ value = length_bitstring(<<"California">>)
+ },
+ LocalityName = #'AttributeTypeAndValue'{
+ type = {2,5,4,7},
+ value = length_bitstring(<<"San Jose">>)
+ },
+ OrganizationName = #'AttributeTypeAndValue'{
+ type = {2,5,4,10},
+ value = length_bitstring(<<"Example">>)
+ },
+ CRI = #'CertificationRequestInfo'{
+ version = 0,
+ % subject = {rdnSequence, [[CommonName]]},
+ subject = {rdnSequence,
+ [ [CommonName]
+ , [CountryName]
+ , [StateOrProvinceName]
+ , [LocalityName]
+ , [OrganizationName]]},
+ subjectPKInfo = SubPKInfo,
+ attributes = []
+ },
+ EncodedCRI = public_key:der_encode(
+ 'CertificationRequestInfo',
+ CRI),
+
+ SignedCRI = public_key:sign(EncodedCRI, 'sha256', PrivateKey),
+
+ SigningAlgoID = #'CertificationRequest_signatureAlgorithm'{
+ algorithm = [1,2,840,10045,4,3,2], %% Very dirty
+ parameters = asn1_NOVALUE
+ },
+
+ CSR = #'CertificationRequest'{
+ certificationRequestInfo = CRI,
+ signatureAlgorithm = SigningAlgoID,
+ signature = SignedCRI
+ },
+ Result = public_key:der_encode(
+ 'CertificationRequest',
+ CSR),
+ % io:format("My CSR: ~p~n", [CSR]),
+
+ Result.
+
+%% TODO: Find a correct function to do this
+length_bitstring(Bitstring) ->
+ Size = size(Bitstring),
+ case Size < 127 of
+ true ->
+ <<12, Size, Bitstring/binary>>;
+ false ->
+ error(not_implemented)
+ end.
+
+scenario() ->
+ % scenario_new_account().
+ scenario_old_account().
+
+scenario_old_account() ->
+ {ok, Pid} = start(),
+ io:format("Server started: ~p~n", [Pid]),
+
+ {ok, Result} = directory(Pid, []),
+ io:format("Directory result: ~p~n", [Result]),
+
+ %% Get the info of an existing account
+ % {ok, {Status1, Head1, Body1}} = account_info(Pid, ?DEFAULT_ACCOUNT),
+ % io:format("Account: ~p~nHead: ~p~nBody: ~p~n",
+ % [?DEFAULT_ACCOUNT, {Status1, Head1}, jiffy:decode(Body1)]),
+
+ %% Update the account to agree to terms and services
+ {ok, {Status1, Head1, Body1}} = update_account(Pid, ?DEFAULT_ACCOUNT),
+ io:format("Account: ~p~nHead: ~p~nBody: ~p~n",
+ [?DEFAULT_ACCOUNT, {Status1, Head1}, jiffy:decode(Body1)]),
+
+ %% New authorization
+ % {ok, {Status2, Head2, Body2}} = new_authz(Pid, []),
+ % io:format("New Authz~nHead: ~p~nBody: ~p~n",
+ % [{Status2, Head2}, jiffy:decode(Body2)]),
+
+ %% Get authorization
+ {ok, {Status2, Head2, Body2}} = get_authz(Pid, []),
+ io:format("Get Authz~nHead: ~p~nBody: ~p~n",
+ [{Status2, Head2}, jiffy:decode(Body2)]),
+
+ % Challenges = get_challenges(jiffy:decode(Body2)),
+ % io:format("Challenges: ~p~n", [Challenges]),
+
+ % ChallengeObjects = acme_challenge:challenges_to_objects(Challenges),
+ % % io:format("Challenges: ~p~n", [ChallengeObjects]),
+
+ % %% Create a key-authorization
+ % Key = jose_jwk:from_file(?DEFAULT_KEY_FILE),
+ % % acme_challenge:key_authorization(<<"pipi">>, Key),
+
+ % Solutions = acme_challenge:solve_challenges(ChallengeObjects, Key),
+ % io:format("Solutions: ~p~n", [Solutions]),
+
+ % {ok, {Status3, Head3, Body3}} =
+ % complete_challenge(Pid, [X || X <- Solutions, X =/= ok]),
+ % io:format("Complete_challenge~nHead: ~p~nBody: ~p~n",
+ % [{Status3, Head3}, jiffy:decode(Body3)]),
+
+ % Get a certification
+ {ok, {Status4, Head4, Body4}} =
+ new_cert(Pid, []),
+ io:format("New Cert~nHead: ~p~nBody: ~p~n",
+ [{Status4, Head4}, Body4]),
+
+ % make_csr(),
+
+ ok.
+
+scenario_new_account() ->
+ {ok, Pid} = start(),
+ io:format("Server started: ~p~n", [Pid]),
+
+ {ok, Result} = directory(Pid, []),
+ io:format("Directory result: ~p~n", [Result]),
+
+ %% Request the creation of a new account
+ {ok, {Status, Head, Body}} = new_reg(Pid, []),
+ io:format("New account~nHead: ~p~nBody: ~p~n", [{Status, Head}, jiffy:decode(Body)]). \ No newline at end of file
diff --git a/src/ejabberd_acme.erl b/src/ejabberd_acme.erl
new file mode 100644
index 000000000..cb7b6525e
--- /dev/null
+++ b/src/ejabberd_acme.erl
@@ -0,0 +1,881 @@
+-module (ejabberd_acme).
+
+-export([%% Ejabberdctl Commands
+ get_certificates/2,
+ list_certificates/1,
+ %% Command Options Validity
+ is_valid_account_opt/1,
+ is_valid_verbose_opt/1,
+ %% Misc
+ generate_key/0,
+ %% Debugging Scenarios
+ scenario/3,
+ scenario0/2,
+ new_user_scenario/2
+ %% 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").
+
+
+
+%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%
+%%
+%% 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.
+
+%%
+%% List Certificates
+%%
+
+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, get_certificates}
+ end.
+
+list_certificates0(Verbose) ->
+ {ok, Certs} = read_certificates_persistent(),
+ case Verbose of
+ "plain" ->
+ [format_certificate(DataCert) || {_Key, DataCert} <- Certs];
+ "verbose" ->
+ Certs
+ end.
+
+%% TODO: Make this cleaner and more robust
+format_certificate(DataCert) ->
+ #data_cert{
+ domain = DomainName,
+ pem = PemCert,
+ path = Path
+ } = DataCert,
+
+ PemList = public_key:pem_decode(PemCert),
+ PemEntryCert = lists:keyfind('Certificate', 1, PemList),
+ Certificate = public_key:pem_entry_decode(PemEntryCert),
+
+ %% Find the commonName
+ _CommonName = get_commonName(Certificate),
+
+ %% Find the notAfter date
+ NotAfter = get_notAfter(Certificate),
+
+ format_certificate1(DomainName, NotAfter, Path).
+
+format_certificate1(DomainName, NotAfter, Path) ->
+ Result = lists:flatten(io_lib:format(
+ " Domain: ~s~n"
+ " Valid until: ~s UTC~n"
+ " Path: ~s",
+ [DomainName, NotAfter, Path])),
+ Result.
+
+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.
+
+get_notAfter(#'Certificate'{tbsCertificate = TbsCertificate}) ->
+ #'TBSCertificate'{
+ validity = Validity
+ } = TbsCertificate,
+
+ %% TODO: Find a library function to decode utc time
+ #'Validity'{notAfter = {utcTime, UtcTime}} = Validity,
+ [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]])),
+
+ NotAfter.
+
+%%
+%% Get Certificate
+%%
+
+%% Needs a hell lot of cleaning
+-spec get_certificates(url(), account_opt()) ->
+ [{'ok', bitstring(), 'saved'} | {'error', bitstring(), _}] |
+ {'error', _}.
+get_certificates(CAUrl, NewAccountOpt) ->
+ try
+ ?INFO_MSG("Persistent: ~p~n", [file:read_file_info(persistent_file())]),
+ get_certificates0(CAUrl, NewAccountOpt)
+ 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(), account_opt()) ->
+ [{'ok', bitstring(), 'saved'} | {'error', bitstring(), _}] |
+ no_return().
+get_certificates0(CAUrl, "old-account") ->
+ %% Get the current account
+ {ok, _AccId, PrivateKey} = ensure_account_exists(),
+
+ get_certificates1(CAUrl, PrivateKey);
+
+get_certificates0(CAUrl, "new-account") ->
+ %% Create a new account and save it to disk
+ {ok, _Id, PrivateKey} = create_save_new_account(CAUrl),
+
+ get_certificates1(CAUrl, PrivateKey).
+
+-spec get_certificates1(url(), jose_jwk:key()) ->
+ [{'ok', bitstring(), 'saved'} | {'error', bitstring(), _}] |
+ no_return().
+get_certificates1(CAUrl, PrivateKey) ->
+ %% Read Config
+ {ok, Hosts} = get_config_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
+ %% Result
+ SavedCerts.
+
+-spec get_certificate(url(), bitstring(), jose_jwk:key()) ->
+ {'ok', bitstring(), pem_certificate()} |
+ {'error', bitstring(), _}.
+get_certificate(CAUrl, DomainName, PrivateKey) ->
+ ?INFO_MSG("Getting a Certificate for domain: ~p~n", [DomainName]),
+ try
+ {ok, _Authz} = create_new_authorization(CAUrl, DomainName, PrivateKey),
+ create_new_certificate(CAUrl, DomainName, 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
+ {ok, 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, 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) ->
+ 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})
+ end.
+
+create_new_certificate(CAUrl, DomainName, PrivateKey) ->
+ try
+ {ok, Dirs, Nonce0} = ejabberd_acme_comm:directory(CAUrl),
+ CSRSubject = [{commonName, bitstring_to_list(DomainName)}],
+ {CSR, CSRKey} = make_csr(CSRSubject),
+ {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.
+
+ensure_account_exists() ->
+ case read_account_persistent() of
+ none ->
+ ?ERROR_MSG("No existing account", []),
+ throw({error, no_old_account});
+ {ok, AccId, PrivateKey} ->
+ {ok, AccId, PrivateKey}
+ end.
+
+%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%
+%%
+%% Certificate Request Functions
+%%
+%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%
+
+%% For now we accept only generating a key of
+%% specific type for signing the csr
+%% TODO: Make this function handle more signing keys
+%% 1. Derive oid from Key
+%% 2. Derive the whole algo objects from Key
+%% TODO: Encode Strings using length using a library function
+
+-spec make_csr(proplist()) -> {binary(), jose_jwk:key()}.
+make_csr(Attributes) ->
+ Key = generate_key(),
+ {_, KeyKey} = jose_jwk:to_key(Key),
+ KeyPub = jose_jwk: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),
+ CRI = certificate_request_info(SubPKInfo, Subject),
+ {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
+ }.
+
+certificate_request_info(SubPKInfo, Subject) ->
+ #'CertificationRequestInfo'{
+ version = 0,
+ subject = Subject,
+ subjectPKInfo = SubPKInfo,
+ attributes = []
+ }.
+
+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
+ 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(_) -> 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.
+
+not_before_not_after() ->
+ %% TODO: Make notBefore and notAfter like they do it in other clients
+ {MegS, Sec, MicS} = erlang:timestamp(),
+ NotBefore = xmpp_util:encode_timestamp({MegS-1, Sec, MicS}),
+ NotAfter = xmpp_util:encode_timestamp({MegS+1, Sec, MicS}),
+ {NotBefore, NotAfter}.
+
+is_error({error, _}) -> true;
+is_error(_) -> false.
+
+%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%
+%%
+%% Handle the persistent data structure
+%%
+%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%
+
+data_empty() ->
+ [].
+
+%%
+%% Account
+%%
+
+data_get_account(Data) ->
+ case lists:keyfind(account, 1, Data) of
+ {account, #data_acc{id = AccId, key = PrivateKey}} ->
+ {ok, AccId, PrivateKey};
+ false ->
+ none
+ end.
+
+data_set_account(Data, {AccId, PrivateKey}) ->
+ NewAcc = {account, #data_acc{id = AccId, key = PrivateKey}},
+ lists:keystore(account, 1, Data, NewAcc).
+
+%%
+%% Certificates
+%%
+
+data_get_certificates(Data) ->
+ case lists:keyfind(certs, 1, Data) of
+ {certs, Certs} ->
+ {ok, Certs};
+ false ->
+ {ok, []}
+ end.
+
+data_set_certificates(Data, NewCerts) ->
+ lists:keystore(certs, 1, Data, {certs, NewCerts}).
+
+%% ATM we preserve one certificate for each domain
+data_add_certificate(Data, DataCert = #data_cert{domain=Domain}) ->
+ {ok, Certs} = data_get_certificates(Data),
+ NewCerts = lists:keystore(Domain, 1, Certs, {Domain, DataCert}),
+ data_set_certificates(Data, NewCerts).
+
+
+%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%
+%%
+%% Handle Config and Persistence Files
+%%
+%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%
+
+persistent_file() ->
+ MnesiaDir = mnesia:system_info(directory),
+ filename:join(MnesiaDir, "acme.DAT").
+
+%% The persistent file should be rread and written only by its owner
+persistent_file_mode() ->
+ 8#400 + 8#200.
+
+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.
+
+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.
+
+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.
+
+write_account_persistent({AccId, PrivateKey}) ->
+ {ok, Data} = read_persistent(),
+ NewData = data_set_account(Data, {AccId, PrivateKey}),
+ ok = write_persistent(NewData).
+
+read_account_persistent() ->
+ {ok, Data} = read_persistent(),
+ data_get_account(Data).
+
+read_certificates_persistent() ->
+ {ok, Data} = read_persistent(),
+ data_get_certificates(Data).
+
+add_certificate_persistent(DataCert) ->
+ {ok, Data} = read_persistent(),
+ NewData = data_add_certificate(Data, DataCert),
+ ok = write_persistent(NewData).
+
+
+save_certificate({error, _, _} = Error) ->
+ Error;
+save_certificate({ok, DomainName, Cert}) ->
+ try
+ {ok, 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),
+ {ok, DomainName, saved}
+ catch
+ throw:Throw ->
+ Throw;
+ E:R ->
+ ?ERROR_MSG("Unknown ~p:~p, ~p", [E, R, erlang:get_stacktrace()]),
+ {error, DomainName, saving}
+ end.
+
+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.
+
+get_config_acme() ->
+ case ejabberd_config:get_option(acme, undefined) of
+ undefined ->
+ ?ERROR_MSG("No acme configuration has been specified", []),
+ throw({error, configuration});
+ Acme ->
+ {ok, Acme}
+ end.
+
+get_config_contact() ->
+ {ok, Acme} = get_config_acme(),
+ case lists:keyfind(contact, 1, Acme) of
+ {contact, Contact} ->
+ {ok, Contact};
+ false ->
+ ?ERROR_MSG("No contact has been specified", []),
+ throw({error, configuration_contact})
+ end.
+
+get_config_hosts() ->
+ case ejabberd_config:get_option(hosts, undefined) of
+ undefined ->
+ ?ERROR_MSG("No hosts have been specified", []),
+ throw({error, configuration_hosts});
+ Hosts ->
+ {ok, Hosts}
+ end.
+
+get_config_cert_dir() ->
+ case ejabberd_config:get_option(cert_dir, undefined) of
+ undefined ->
+ ?ERROR_MSG("No cert_dir configuration has been specified", []),
+ throw({error, configuration});
+ CertDir ->
+ {ok, CertDir}
+ end.
+
+%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%
+%%
+%% Transaction Fun
+%%
+%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%
+
+transaction([{Fun, Rollback} | Rest]) ->
+ try
+ {ok, Result} = Fun(),
+ [Result | transaction(Rest)]
+ catch Type:Reason ->
+ Rollback(),
+ erlang:raise(Type, Reason, erlang:get_stacktrace())
+ end;
+transaction([Fun | Rest]) ->
+ % not every action require cleanup on error
+ transaction([{Fun, fun () -> ok end} | Rest]);
+transaction([]) -> [].
+
+%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%
+%%
+%% Debugging Funcs -- They are only used for the development phase
+%%
+%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%
+
+%% A typical acme workflow
+scenario(CAUrl, AccId, PrivateKey) ->
+ {ok, Dirs, Nonce0} = ejabberd_acme_comm:directory(CAUrl),
+
+ {ok, {_TOS, Account}, Nonce1} =
+ ejabberd_acme_comm:get_account({CAUrl, AccId}, PrivateKey, Nonce0),
+ ?INFO_MSG("Account: ~p~n", [Account]),
+
+ Req =
+ [{<<"identifier">>,
+ {[{<<"type">>, <<"dns">>},
+ {<<"value">>, <<"my-acme-test-ejabberd.com">>}]}},
+ {<<"existing">>, <<"accept">>}
+ ],
+ {ok, Authz, Nonce2} = ejabberd_acme_comm:new_authz(Dirs, PrivateKey, Req, Nonce1),
+
+ {Account, Authz, PrivateKey}.
+
+
+new_user_scenario(CAUrl, HttpDir) ->
+ PrivateKey = generate_key(),
+
+ {ok, Dirs, Nonce0} = ejabberd_acme_comm:directory(CAUrl),
+ %% ?INFO_MSG("Directories: ~p", [Dirs]),
+
+ Req0 = [{ <<"contact">>, [<<"mailto:cert-example-admin@example2.com">>]}],
+ {ok, {TOS, Account}, Nonce1} = ejabberd_acme_comm:new_account(Dirs, PrivateKey, Req0, Nonce0),
+
+ {_, AccIdInt} = proplists:lookup(<<"id">>, Account),
+ AccId = integer_to_list(AccIdInt),
+ {ok, {_TOS, Account1}, Nonce2} =
+ ejabberd_acme_comm:get_account({CAUrl, AccId}, PrivateKey, Nonce1),
+ %% ?INFO_MSG("Old account: ~p~n", [Account1]),
+
+ Req1 = [{ <<"agreement">>, list_to_bitstring(TOS)}],
+ {ok, Account2, Nonce3} =
+ ejabberd_acme_comm:update_account({CAUrl, AccId}, PrivateKey, Req1, Nonce2),
+
+ %% NewKey = generate_key(),
+ %% KeyChangeUrl = CAUrl ++ "/acme/key-change/",
+ %% {ok, Account3, Nonce4} = key_roll_over(KeyChangeUrl, AccURL, PrivateKey, NewKey, Nonce3),
+ %% ?INFO_MSG("Changed key: ~p~n", [Account3]),
+
+ %% {ok, {_TOS, Account4}, Nonce5} = get_account(AccURL, NewKey, Nonce4),
+ %% ?INFO_MSG("New account:~p~n", [Account4]),
+ %% {Account4, PrivateKey}.
+
+ AccIdBin = list_to_bitstring(integer_to_list(AccIdInt)),
+ DomainName = << <<"my-acme-test-ejabberd">>/binary, AccIdBin/binary, <<".com">>/binary >>,
+ Req2 =
+ [{<<"identifier">>,
+ {[{<<"type">>, <<"dns">>},
+ {<<"value">>, DomainName}]}},
+ {<<"existing">>, <<"accept">>}
+ ],
+ {ok, {AuthzUrl, Authz}, Nonce4} =
+ ejabberd_acme_comm:new_authz(Dirs, PrivateKey, Req2, Nonce3),
+
+ {ok, AuthzId} = location_to_id(AuthzUrl),
+ {ok, Authz2, Nonce5} = ejabberd_acme_comm:get_authz({CAUrl, AuthzId}),
+ ?INFO_MSG("AuthzUrl: ~p~n", [AuthzUrl]),
+
+ Challenges = get_challenges(Authz2),
+ ?INFO_MSG("Challenges: ~p~n", [Challenges]),
+
+ {ok, ChallengeUrl, KeyAuthz} =
+ acme_challenge:solve_challenge(<<"http-01">>, Challenges, {PrivateKey, HttpDir}),
+ ?INFO_MSG("File for http-01 challenge written correctly", []),
+
+ {ok, ChallengeId} = location_to_id(ChallengeUrl),
+ Req3 =
+ [ {<<"type">>, <<"http-01">>}
+ , {<<"keyAuthorization">>, KeyAuthz}
+ ],
+ {ok, SolvedChallenge, Nonce6} = ejabberd_acme_comm:complete_challenge(
+ {CAUrl, AuthzId, ChallengeId}, PrivateKey, Req3, Nonce5),
+ %% ?INFO_MSG("SolvedChallenge: ~p~n", [SolvedChallenge]),
+
+ %% timer:sleep(2000),
+ {ok, Authz3, Nonce7} = ejabberd_acme_comm:get_authz_until_valid({CAUrl, AuthzId}),
+
+ #{"new-cert" := NewCert} = Dirs,
+ CSRSubject = [{commonName, bitstring_to_list(DomainName)}],
+ {CSR, CSRKey} = make_csr(CSRSubject),
+ {MegS, Sec, MicS} = erlang:timestamp(),
+ NotBefore = xmpp_util:encode_timestamp({MegS-1, Sec, MicS}),
+ NotAfter = xmpp_util:encode_timestamp({MegS+1, Sec, MicS}),
+ Req4 =
+ [{<<"csr">>, CSR},
+ {<<"notBefore">>, NotBefore},
+ {<<"NotAfter">>, NotAfter}
+ ],
+ {ok, {CertUrl, Certificate}, Nonce8} =
+ ejabberd_acme_comm:new_cert(Dirs, PrivateKey, Req4, Nonce7),
+ ?INFO_MSG("CertUrl: ~p~n", [CertUrl]),
+
+ {ok, CertId} = location_to_id(CertUrl),
+ {ok, Certificate2, Nonce9} = ejabberd_acme_comm:get_cert({CAUrl, CertId}),
+
+ DecodedCert = public_key:pkix_decode_cert(list_to_binary(Certificate2), plain),
+ %% ?INFO_MSG("DecodedCert: ~p~n", [DecodedCert]),
+ PemEntryCert = public_key:pem_entry_encode('Certificate', DecodedCert),
+ %% ?INFO_MSG("PemEntryCert: ~p~n", [PemEntryCert]),
+
+ {_, CSRKeyKey} = jose_jwk:to_key(CSRKey),
+ PemEntryKey = public_key:pem_entry_encode('ECPrivateKey', CSRKeyKey),
+ %% ?INFO_MSG("PemKey: ~p~n", [jose_jwk:to_pem(CSRKey)]),
+ %% ?INFO_MSG("PemEntryKey: ~p~n", [PemEntryKey]),
+
+ PemCert = public_key:pem_encode([PemEntryKey, PemEntryCert]),
+ %% ?INFO_MSG("PemCert: ~p~n", [PemCert]),
+
+ ok = file:write_file(HttpDir ++ "/my_server.pem", PemCert),
+
+ Base64Cert = base64url:encode(Certificate2),
+ Req5 = [{<<"certificate">>, Base64Cert}],
+ {ok, [], Nonce10} = ejabberd_acme_comm:revoke_cert(Dirs, PrivateKey, Req5, Nonce9),
+
+ {ok, Certificate3, Nonce11} = ejabberd_acme_comm:get_cert({CAUrl, CertId}),
+
+ {Account2, Authz3, CSR, Certificate, PrivateKey}.
+
+-ifdef(GENERATE_RSA_KEY).
+generate_key() ->
+ ?INFO_MSG("Generate RSA key pair~n", []),
+ Key = public_key:generate_key({rsa, 2048, 65537}),
+ jose_jwk:from_key(Key).
+-else.
+generate_key() ->
+ ?INFO_MSG("Generate EC key pair~n", []),
+ jose_jwk:generate_key({ec, secp256r1}).
+-endif.
+
+
+scenario3() ->
+ CSRSubject = [{commonName, "my-acme-test-ejabberd.com"},
+ {organizationName, "Example Corp"}],
+ {CSR, CSRKey} = make_csr(CSRSubject).
+
+
+%% It doesn't seem to work, The user can get a new authorization even though the account has been deleted
+delete_account_scenario(CAUrl) ->
+ PrivateKey = generate_key(),
+
+ DirURL = CAUrl ++ "/directory",
+ {ok, Dirs, Nonce0} = ejabberd_acme_comm:directory(DirURL),
+ %% ?INFO_MSG("Directories: ~p", [Dirs]),
+
+ Req0 = [{ <<"contact">>, [<<"mailto:cert-example-admin@example2.com">>]}],
+ {ok, {TOS, Account}, Nonce1} = ejabberd_acme_comm:new_account(Dirs, PrivateKey, Req0, Nonce0),
+
+ {_, AccIdInt} = proplists:lookup(<<"id">>, Account),
+ AccId = integer_to_list(AccIdInt),
+ {ok, {_TOS, Account1}, Nonce2} =
+ ejabberd_acme_comm:get_account({CAUrl, AccId}, PrivateKey, Nonce1),
+ %% ?INFO_MSG("Old account: ~p~n", [Account1]),
+
+ Req1 = [{ <<"agreement">>, list_to_bitstring(TOS)}],
+ {ok, Account2, Nonce3} =
+ ejabberd_acme_comm:update_account({CAUrl, AccId}, PrivateKey, Req1, Nonce2),
+
+ %% Delete account
+ {ok, Account3, Nonce4} =
+ ejabberd_acme_comm:delete_account({CAUrl, AccId}, PrivateKey, Nonce3),
+
+ timer:sleep(3000),
+
+ {ok, {_TOS, Account4}, Nonce5} =
+ ejabberd_acme_comm:get_account({CAUrl, AccId}, PrivateKey, Nonce4),
+ ?INFO_MSG("New account: ~p~n", [Account4]),
+
+ AccIdBin = list_to_bitstring(integer_to_list(AccIdInt)),
+ DomainName = << <<"my-acme-test-ejabberd">>/binary, AccIdBin/binary, <<".com">>/binary >>,
+ Req2 =
+ [{<<"identifier">>,
+ {[{<<"type">>, <<"dns">>},
+ {<<"value">>, DomainName}]}},
+ {<<"existing">>, <<"accept">>}
+ ],
+ {ok, {AuthzUrl, Authz}, Nonce6} =
+ ejabberd_acme_comm:new_authz(Dirs, PrivateKey, Req2, Nonce5),
+
+ {ok, Account1, Account3, Authz}.
+
+%% Just a test
+scenario0(KeyFile, HttpDir) ->
+ PrivateKey = jose_jwk:from_file(KeyFile),
+ %% scenario("http://localhost:4000", "2", PrivateKey).
+ %% delete_account_scenario("http://localhost:4000").
+ new_user_scenario("http://localhost:4000", HttpDir).
+
+%% scenario3().
+
diff --git a/src/ejabberd_acme_comm.erl b/src/ejabberd_acme_comm.erl
new file mode 100644
index 000000000..b66d5f610
--- /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],
+ 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 = jose_jwk: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, _}, _Head, Body}}, Url) ->
+ ?ERROR_MSG("Got unexpected status code from <~s>: ~B, Body: ~s",
+ [Url, Code, Body]),
+ {error, unexpected_code};
+failed_http_request({error, Reason}, Url) ->
+ ?ERROR_MSG("Error making a request to <~s>: ~p",
+ [Url, Reason]),
+ {error, Reason}.
diff --git a/src/ejabberd_admin.erl b/src/ejabberd_admin.erl
index 8b4af2857..af0cb978b 100644
--- a/src/ejabberd_admin.erl
+++ b/src/ejabberd_admin.erl
@@ -44,6 +44,9 @@
registered_users/1,
%% Migration jabberd1.4
import_file/1, import_dir/1,
+ %% Acme
+ get_certificate/1,
+ list_certificates/1,
%% Purge DB
delete_expired_messages/0, delete_old_messages/1,
%% Mnesia
@@ -104,7 +107,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 +129,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 +195,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 +218,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">>],
@@ -243,6 +246,19 @@ get_commands_spec() ->
args = [{file, string}],
result = {res, restuple}},
+ #ejabberd_commands{name = get_certificate, tags = [acme],
+ desc = "Gets a certificate for the specified domain",
+ module = ?MODULE, function = get_certificate,
+ args_desc = ["Whether to create a new account or use the existing one"],
+ args = [{option, string}],
+ result = {certificate, string}},
+ #ejabberd_commands{name = list_certificates, tags = [acme],
+ desc = "Lists all curently handled certificates and their respective domains",
+ 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 = import_piefxis, tags = [mnesia],
desc = "Import users data from a PIEFXIS file (XEP-0227)",
module = ejabberd_piefxis, function = import_file,
@@ -321,9 +337,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 +437,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 +485,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 +516,7 @@ registered_users(Host) ->
lists:map(fun({U, _S}) -> U end, SUsers).
registered_vhosts() ->
- ?MYHOSTS.
+ ?MYHOSTS.
reload_config() ->
ejabberd_config:reload_file().
@@ -542,6 +558,28 @@ import_dir(Path) ->
{cannot_import_dir, String}
end.
+%%%
+%%% Acme
+%%%
+
+get_certificate(UseNewAccount) ->
+ case ejabberd_acme:is_valid_account_opt(UseNewAccount) of
+ true ->
+ ejabberd_acme:get_certificates("http://localhost:4000", UseNewAccount);
+ false ->
+ String = io_lib:format("Invalid account option: ~p", [UseNewAccount]),
+ {invalid_option, String}
+ end.
+
+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.
+
%%%
%%% Purge DB
@@ -726,45 +764,45 @@ mnesia_change_nodename(FromString, ToString, Source, Target) ->
Switch =
fun
(Node) when Node == From ->
- io:format(" - Replacing nodename: '~p' with: '~p'~n", [From, To]),
- To;
- (Node) when Node == To ->
+ io:format(" - Replacing nodename: '~p' with: '~p'~n", [From, To]),
+ To;
+ (Node) when Node == To ->
%% throw({error, already_exists});
- io:format(" - Node: '~p' will not be modified (it is already '~p')~n", [Node, To]),
- Node;
- (Node) ->
- io:format(" - Node: '~p' will not be modified (it is not '~p')~n", [Node, From]),
- Node
- end,
- Convert =
- fun
- ({schema, db_nodes, Nodes}, Acc) ->
- io:format(" +++ db_nodes ~p~n", [Nodes]),
- {[{schema, db_nodes, lists:map(Switch,Nodes)}], Acc};
- ({schema, version, Version}, Acc) ->
- io:format(" +++ version: ~p~n", [Version]),
- {[{schema, version, Version}], Acc};
- ({schema, cookie, Cookie}, Acc) ->
- io:format(" +++ cookie: ~p~n", [Cookie]),
- {[{schema, cookie, Cookie}], Acc};
- ({schema, Tab, CreateList}, Acc) ->
- io:format("~n * Checking table: '~p'~n", [Tab]),
- Keys = [ram_copies, disc_copies, disc_only_copies],
- OptSwitch =
- fun({Key, Val}) ->
- case lists:member(Key, Keys) of
- true ->
- io:format(" + Checking key: '~p'~n", [Key]),
- {Key, lists:map(Switch, Val)};
- false-> {Key, Val}
- end
- end,
- Res = {[{schema, Tab, lists:map(OptSwitch, CreateList)}], Acc},
- Res;
- (Other, Acc) ->
- {[Other], Acc}
- end,
- mnesia:traverse_backup(Source, Target, Convert, switched).
+ io:format(" - Node: '~p' will not be modified (it is already '~p')~n", [Node, To]),
+ Node;
+ (Node) ->
+ io:format(" - Node: '~p' will not be modified (it is not '~p')~n", [Node, From]),
+ Node
+ end,
+Convert =
+fun
+ ({schema, db_nodes, Nodes}, Acc) ->
+ io:format(" +++ db_nodes ~p~n", [Nodes]),
+ {[{schema, db_nodes, lists:map(Switch,Nodes)}], Acc};
+ ({schema, version, Version}, Acc) ->
+ io:format(" +++ version: ~p~n", [Version]),
+ {[{schema, version, Version}], Acc};
+ ({schema, cookie, Cookie}, Acc) ->
+ io:format(" +++ cookie: ~p~n", [Cookie]),
+ {[{schema, cookie, Cookie}], Acc};
+ ({schema, Tab, CreateList}, Acc) ->
+ io:format("~n * Checking table: '~p'~n", [Tab]),
+ Keys = [ram_copies, disc_copies, disc_only_copies],
+ OptSwitch =
+ fun({Key, Val}) ->
+ case lists:member(Key, Keys) of
+ true ->
+ io:format(" + Checking key: '~p'~n", [Key]),
+ {Key, lists:map(Switch, Val)};
+ false-> {Key, Val}
+ end
+ end,
+ Res = {[{schema, Tab, lists:map(OptSwitch, CreateList)}], Acc},
+ Res;
+ (Other, Acc) ->
+ {[Other], Acc}
+end,
+mnesia:traverse_backup(Source, Target, Convert, switched).
clear_cache() ->
Nodes = ejabberd_cluster:get_nodes(),