diff options
-rw-r--r-- | ejabberd.yml.example | 13 | ||||
-rw-r--r-- | include/ejabberd_acme.hrl | 34 | ||||
-rw-r--r-- | rebar.config | 3 | ||||
-rwxr-xr-x | run_acme.sh | 9 | ||||
-rw-r--r-- | src/acme_challenge.erl | 137 | ||||
-rw-r--r-- | src/acme_experimental.erl | 607 | ||||
-rw-r--r-- | src/ejabberd_acme.erl | 881 | ||||
-rw-r--r-- | src/ejabberd_acme_comm.erl | 393 | ||||
-rw-r--r-- | src/ejabberd_admin.erl | 138 |
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(), |