diff options
Diffstat (limited to 'src/ejabberd_acme_comm.erl')
-rw-r--r-- | src/ejabberd_acme_comm.erl | 393 |
1 files changed, 393 insertions, 0 deletions
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}. |