From 56fc0efbc872891991d4f9ce0a24d43101795a03 Mon Sep 17 00:00:00 2001 From: Konstantinos Kallas Date: Tue, 4 Jul 2017 11:44:22 +0300 Subject: [PATCH] Split ACME module into two 1. A communications module that handles all requets/responses and other low level stuff that have to do with the ACME CA 2. A head module that will do all the useful stuff --- src/ejabberd_acme.erl | 454 +++++-------------------------------- src/ejabberd_acme_comm.erl | 387 +++++++++++++++++++++++++++++++ 2 files changed, 440 insertions(+), 401 deletions(-) create mode 100644 src/ejabberd_acme_comm.erl diff --git a/src/ejabberd_acme.erl b/src/ejabberd_acme.erl index aa7c0ac37..e1cee923e 100644 --- a/src/ejabberd_acme.erl +++ b/src/ejabberd_acme.erl @@ -1,24 +1,11 @@ -module (ejabberd_acme). --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, - %% Certificate - new_cert/4, - get_cert/1, - revoke_cert/4, - %% Ejabberdctl Commands +-export([%% Ejabberdctl Commands get_certificates/3, %% Command Options Validity is_valid_account_opt/1, + %% Misc + generate_key/0, %% Debugging Scenarios scenario/3, scenario0/2, @@ -35,144 +22,6 @@ -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. - - -%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%% -%% -%% 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}. - %%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%% %% @@ -320,143 +169,6 @@ attribute_oid(organizationName) -> ?'id-at-organizationName'; attribute_oid(_) -> error(bad_attributes). -%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%% -%% -%% 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). - %%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%% %% @@ -464,20 +176,6 @@ sign_encode_json_jose(Key, Json, Nonce) -> %% %%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%% --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. - -spec location_to_id(url()) -> {ok, string()} | {error, not_found}. location_to_id(Url0) -> Url = string:strip(Url0, right, $/), @@ -489,74 +187,14 @@ location_to_id(Url0) -> {ok, string:sub_string(Url, Ind+1)} 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. - -spec get_challenges(proplist()) -> [{proplist()}]. get_challenges(Body) -> {<<"challenges">>, Challenges} = proplists:lookup(<<"challenges">>, Body), Challenges. -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. - is_error({error, _}) -> true; is_error(_) -> false. -%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%% -%% -%% 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}. - %%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%% %% %% Handle Config and Persistence Files @@ -670,11 +308,11 @@ get_certificates0(CAUrl, HttpDir, "new-account") -> PrivateKey = generate_key(), %% Create a new account - {ok, _Id} = create_new_account(CAUrl, Contact, Key), + {ok, Id} = create_new_account(CAUrl, Contact, PrivateKey), %% Write Persistent Data {ok, Data} = read_persistent(), - NewData = set_account_persistent(Data, {Id, Key}), + NewData = set_account_persistent(Data, {Id, PrivateKey}), ok = write_persistent(NewData), get_certificates1(CAUrl, HttpDir, PrivateKey). @@ -686,13 +324,13 @@ get_certificates1(CAUrl, HttpDir, PrivateKey) -> %% Get a certificate for each host PemCertKeys = [get_certificate(CAUrl, Host, PrivateKey, HttpDir) || Host <- Hosts], - {AccId, PrivateKey, PemCertKeys}. + {ok, PrivateKey, PemCertKeys}. get_certificate(CAUrl, DomainName, PrivateKey, HttpDir) -> ?INFO_MSG("Getting a Certificate for domain: ~p~n", [DomainName]), case create_new_authorization(CAUrl, DomainName, PrivateKey, HttpDir) of - {ok, Authz} -> + {ok, _Authz} -> create_new_certificate(CAUrl, DomainName, PrivateKey); {error, authorization} -> {error, {authorization, {host, DomainName}}} @@ -702,13 +340,15 @@ get_certificate(CAUrl, DomainName, PrivateKey, HttpDir) -> %% Find a way to ask the user if he accepts the TOS create_new_account(CAUrl, Contact, PrivateKey) -> try - {ok, Dirs, Nonce0} = directory(CAUrl), + {ok, Dirs, Nonce0} = ejabberd_acme_comm:directory(CAUrl), Req0 = [{ <<"contact">>, [Contact]}], - {ok, {TOS, Account}, Nonce1} = new_account(Dirs, PrivateKey, Req0, Nonce0), + {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} = update_account({CAUrl, AccId}, PrivateKey, Req1, Nonce1), + {ok, _Account2, _Nonce2} = + ejabberd_acme_comm:update_account({CAUrl, AccId}, PrivateKey, Req1, Nonce1), {ok, AccId} catch E:R -> @@ -718,12 +358,13 @@ create_new_account(CAUrl, Contact, PrivateKey) -> create_new_authorization(CAUrl, DomainName, PrivateKey, HttpDir) -> try - {ok, Dirs, Nonce0} = directory(CAUrl), + {ok, Dirs, Nonce0} = ejabberd_acme_comm:directory(CAUrl), Req0 = [{<<"identifier">>, {[{<<"type">>, <<"dns">>}, {<<"value">>, DomainName}]}}, {<<"existing">>, <<"accept">>}], - {ok, {AuthzUrl, Authz}, Nonce1} = new_authz(Dirs, PrivateKey, Req0, Nonce0), + {ok, {AuthzUrl, Authz}, Nonce1} = + ejabberd_acme_comm:new_authz(Dirs, PrivateKey, Req0, Nonce0), {ok, AuthzId} = location_to_id(AuthzUrl), Challenges = get_challenges(Authz), @@ -731,10 +372,10 @@ create_new_authorization(CAUrl, DomainName, PrivateKey, HttpDir) -> acme_challenge:solve_challenge(<<"http-01">>, Challenges, {PrivateKey, HttpDir}), {ok, ChallengeId} = location_to_id(ChallengeUrl), Req3 = [{<<"type">>, <<"http-01">>},{<<"keyAuthorization">>, KeyAuthz}], - {ok, SolvedChallenge, Nonce2} = - complete_challenge({CAUrl, AuthzId, ChallengeId}, PrivateKey, Req3, Nonce1), + {ok, SolvedChallenge, Nonce2} = ejabberd_acme_comm:complete_challenge( + {CAUrl, AuthzId, ChallengeId}, PrivateKey, Req3, Nonce1), - {ok, AuthzValid, _Nonce} = get_authz_until_valid({CAUrl, AuthzId}), + {ok, AuthzValid, _Nonce} = ejabberd_acme_comm:get_authz_until_valid({CAUrl, AuthzId}), {ok, AuthzValid} catch E:R -> @@ -745,7 +386,7 @@ create_new_authorization(CAUrl, DomainName, PrivateKey, HttpDir) -> create_new_certificate(CAUrl, DomainName, PrivateKey) -> try - {ok, Dirs, Nonce0} = directory(CAUrl), + {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(), @@ -754,7 +395,7 @@ create_new_certificate(CAUrl, DomainName, PrivateKey) -> {<<"notBefore">>, NotBefore}, {<<"NotAfter">>, NotAfter} ], - {ok, {CertUrl, Certificate}, Nonce1} = new_cert(Dirs, PrivateKey, Req, Nonce0), + {ok, {CertUrl, Certificate}, Nonce1} = ejabberd_acme_comm:new_cert(Dirs, PrivateKey, Req, Nonce0), {ok, CertId} = location_to_id(CertUrl), @@ -788,9 +429,10 @@ not_before_not_after() -> %% A typical acme workflow scenario(CAUrl, AccId, PrivateKey) -> - {ok, Dirs, Nonce0} = directory(CAUrl), + {ok, Dirs, Nonce0} = ejabberd_acme_comm:directory(CAUrl), - {ok, {_TOS, Account}, Nonce1} = get_account({CAUrl, AccId}, PrivateKey, Nonce0), + {ok, {_TOS, Account}, Nonce1} = + ejabberd_acme_comm:get_account({CAUrl, AccId}, PrivateKey, Nonce0), ?INFO_MSG("Account: ~p~n", [Account]), Req = @@ -799,7 +441,7 @@ scenario(CAUrl, AccId, PrivateKey) -> {<<"value">>, <<"my-acme-test-ejabberd.com">>}]}}, {<<"existing">>, <<"accept">>} ], - {ok, Authz, Nonce2} = new_authz(Dirs, PrivateKey, Req, Nonce1), + {ok, Authz, Nonce2} = ejabberd_acme_comm:new_authz(Dirs, PrivateKey, Req, Nonce1), {Account, Authz, PrivateKey}. @@ -807,19 +449,21 @@ scenario(CAUrl, AccId, PrivateKey) -> new_user_scenario(CAUrl, HttpDir) -> PrivateKey = generate_key(), - {ok, Dirs, Nonce0} = directory(CAUrl), + {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} = new_account(Dirs, PrivateKey, Req0, Nonce0), + {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} = get_account({CAUrl, AccId}, PrivateKey, Nonce1), + {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} = update_account({CAUrl, AccId}, PrivateKey, Req1, Nonce2), + {ok, Account2, Nonce3} = + ejabberd_acme_comm:update_account({CAUrl, AccId}, PrivateKey, Req1, Nonce2), %% NewKey = generate_key(), %% KeyChangeUrl = CAUrl ++ "/acme/key-change/", @@ -838,10 +482,11 @@ new_user_scenario(CAUrl, HttpDir) -> {<<"value">>, DomainName}]}}, {<<"existing">>, <<"accept">>} ], - {ok, {AuthzUrl, Authz}, Nonce4} = new_authz(Dirs, PrivateKey, Req2, Nonce3), + {ok, {AuthzUrl, Authz}, Nonce4} = + ejabberd_acme_comm:new_authz(Dirs, PrivateKey, Req2, Nonce3), {ok, AuthzId} = location_to_id(AuthzUrl), - {ok, Authz2, Nonce5} = get_authz({CAUrl, AuthzId}), + {ok, Authz2, Nonce5} = ejabberd_acme_comm:get_authz({CAUrl, AuthzId}), ?INFO_MSG("AuthzUrl: ~p~n", [AuthzUrl]), Challenges = get_challenges(Authz2), @@ -856,11 +501,12 @@ new_user_scenario(CAUrl, HttpDir) -> [ {<<"type">>, <<"http-01">>} , {<<"keyAuthorization">>, KeyAuthz} ], - {ok, SolvedChallenge, Nonce6} = complete_challenge({CAUrl, AuthzId, ChallengeId}, PrivateKey, Req3, Nonce5), + {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} = get_authz_until_valid({CAUrl, AuthzId}), + {ok, Authz3, Nonce7} = ejabberd_acme_comm:get_authz_until_valid({CAUrl, AuthzId}), #{"new-cert" := NewCert} = Dirs, CSRSubject = [{commonName, bitstring_to_list(DomainName)}, @@ -874,11 +520,12 @@ new_user_scenario(CAUrl, HttpDir) -> {<<"notBefore">>, NotBefore}, {<<"NotAfter">>, NotAfter} ], - {ok, {CertUrl, Certificate}, Nonce8} = new_cert(Dirs, PrivateKey, Req4, Nonce7), + {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} = get_cert({CAUrl, CertId}), + {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]), @@ -897,9 +544,9 @@ new_user_scenario(CAUrl, HttpDir) -> Base64Cert = base64url:encode(Certificate2), Req5 = [{<<"certificate">>, Base64Cert}], - {ok, [], Nonce10} = revoke_cert(Dirs, PrivateKey, Req5, Nonce9), + {ok, [], Nonce10} = ejabberd_acme_comm:revoke_cert(Dirs, PrivateKey, Req5, Nonce9), - {ok, Certificate3, Nonce11} = get_cert(CertUrl), + {ok, Certificate3, Nonce11} = ejabberd_acme_comm:get_cert(CertUrl), {Account2, Authz3, CSR, Certificate, PrivateKey}. @@ -918,26 +565,30 @@ delete_account_scenario(CAUrl) -> PrivateKey = generate_key(), DirURL = CAUrl ++ "/directory", - {ok, Dirs, Nonce0} = directory(DirURL), + {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} = new_account(Dirs, PrivateKey, Req0, Nonce0), + {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} = get_account({CAUrl, AccId}, PrivateKey, Nonce1), + {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} = update_account({CAUrl, AccId}, PrivateKey, Req1, Nonce2), + {ok, Account2, Nonce3} = + ejabberd_acme_comm:update_account({CAUrl, AccId}, PrivateKey, Req1, Nonce2), %% Delete account - {ok, Account3, Nonce4} = delete_account({CAUrl, AccId}, PrivateKey, Nonce3), + {ok, Account3, Nonce4} = + ejabberd_acme_comm:delete_account({CAUrl, AccId}, PrivateKey, Nonce3), timer:sleep(3000), - {ok, {_TOS, Account4}, Nonce5} = get_account({CAUrl, AccId}, PrivateKey, Nonce4), + {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)), @@ -948,7 +599,8 @@ delete_account_scenario(CAUrl) -> {<<"value">>, DomainName}]}}, {<<"existing">>, <<"accept">>} ], - {ok, {AuthzUrl, Authz}, Nonce6} = new_authz(Dirs, PrivateKey, Req2, Nonce5), + {ok, {AuthzUrl, Authz}, Nonce6} = + ejabberd_acme_comm:new_authz(Dirs, PrivateKey, Req2, Nonce5), {ok, Account1, Account3, Authz}. diff --git a/src/ejabberd_acme_comm.erl b/src/ejabberd_acme_comm.erl new file mode 100644 index 000000000..804d46531 --- /dev/null +++ b/src/ejabberd_acme_comm.erl @@ -0,0 +1,387 @@ +-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. + + +%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%% +%% +%% 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}.