%% Copyright 2015-2020 Guillaume Bour %% %% Licensed under the Apache License, Version 2.0 (the "License"); %% you may not use this file except in compliance with the License. %% You may obtain a copy of the License at %% %% http://www.apache.org/licenses/LICENSE-2.0 %% %% Unless required by applicable law or agreed to in writing, software %% distributed under the License is distributed on an "AS IS" BASIS, %% WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. %% See the License for the specific language governing permissions and %% limitations under the License. -module(letsencrypt_api). -author("Guillaume Bour "). -export([directory/2, nonce/2, account/4, order/5, order/4, authorization/4, challenge/4, finalize/5, certificate/4, status/1]). -import(letsencrypt_utils, [str/1]). -ifdef(TEST). -define(STAGING_API_URL, <<"https://127.0.0.1:14000/dir">>). -define(DEFAULT_API_URL, <<"">>). -else. -define(STAGING_API_URL, <<"https://acme-staging-v02.api.letsencrypt.org/directory">>). -define(DEFAULT_API_URL, <<"https://acme-v02.api.letsencrypt.org/directory">>). -endif. -ifdef(DEBUG). -define(debug(Fmt, Args), io:format(Fmt, Args)). -else. -define(debug(Fmt, Args), ok). -endif. -spec status(binary()) -> atom(). status(<<"pending">>) -> pending; status(<<"processing">>) -> processing; status(<<"valid">>) -> valid; status(<<"invalid">>) -> invalid; status(<<"revoked">>) -> revoked; status(_Status) -> io:format("unknown status: ~p~n", [_Status]), unknown. %% PRIVATE % tcpconn({Prototype, Hostname/IP, Port}) % % returns: {ok, ConnID} % % Opened connections are stored in `conns` ets. If a connection to the given Host:Port % is already opened, returns it, either open a new connection. % % TODO: checks connection is still alive (ping ?) -spec tcpconn({http|https, string(), integer()}) -> {ok, pid()}. tcpconn(Key={Proto, Host, Port}) -> case ets:info(conns) of % does not exists undefined -> ets:new(conns, [set, named_table]); _ -> ok end, case ets:lookup(conns, Key) of % not found [] -> %TODO: handle connection failures {ok, Conn} = shotgun:open(Host, Port, Proto), ets:insert(conns, {Key, Conn}), {ok, Conn}; [{_, Conn}] -> {ok, Conn} end. % decode(Option, Result) % % Decodes http body as json if asked, or return as if. % % returns: % {ok, Result} with added json structure if required % -spec decode(map(), map()) -> {ok, map()}. decode(#{json := true}, Response=#{body := Body}) -> Payload = jiffy:decode(Body, [return_maps, use_nil]), {ok, Response#{json => Payload}}; decode(_, Response) -> {ok, Response}. % request(get|post, Uri, Headers, Content, Options) % % Query Uri (get or post) and return results. % % returns: % {ok, #{status_coe, body, headers}} :: query succeed % {error, invalid_method} :: Method MUST be either 'get' or 'post' % {error, term()} :: query failed % % TODO: is 'application/jose+json' content type always required ? % (check acme documentation) -spec request(get|post, string()|binary(), map(), nil|binary(), map()) -> shotgun:result()|{error, invalid_method}. request(Method, Uri, Headers, Content, Opts=#{netopts := Netopts}) -> {ok, {Proto, _, Host, Port, Path, _}} = http_uri:parse(str(Uri)), Headers2 = Headers#{<<"content-type">> => <<"application/jose+json">>}, % we want to reuse connection if exists {ok, Conn} = tcpconn({Proto, Host, Port}), Result = case Method of get -> shotgun:get(Conn, Path, Headers2, Netopts); post -> shotgun:post(Conn, Path, Headers2, Content, Netopts); _ -> {error, invalid_method} end, ?debug("~p(~p) => ~p~n", [Method, Uri, Result]), case Result of {ok, Response=#{headers := RHeaders}} -> R = Response#{ nonce => proplists:get_value(<<"replay-nonce">>, RHeaders, nil), location => proplists:get_value(<<"location">>, RHeaders, nil) }, decode(Opts, R); _ -> Result end. %% %% PUBLIC FUNCTIONS %% % directory(Environment, Options) % % Get directory map listing all acme protocol urls. % ref: https://www.rfc-editor.org/rfc/rfc8555.html#section-7.1.1 % % returns: % {ok, Directory} where Directory is a map containing protocol urls % -spec directory(default|staging, map()) -> {ok, map()}. directory(Env, Opts) -> Uri = case Env of staging -> ?STAGING_API_URL; _ -> ?DEFAULT_API_URL end, ?debug("Getting directory at ~p~n", [Uri]), {ok, #{json := Directory}} = request(get, Uri, #{}, nil, Opts#{json => true}), {ok, Directory}. % nonce(Directory, Options) % % Get a fresh nonce. % ref: https://www.rfc-editor.org/rfc/rfc8555.html#section-7.2 % % returns: % {ok, Nonce} % -spec nonce(map(), map()) -> {ok, binary()}. nonce(#{<<"newNonce">> := Uri}, Opts) -> {ok, #{nonce := Nonce}} = request(get, Uri, #{}, nil, Opts), {ok, Nonce}. % account(Directory, Key, Jws, Opts) % % Request new account. % ref: https://www.rfc-editor.org/rfc/rfc8555.html#section-7.3.1 % % returns: % {ok, Response, Location, Nonce} % - Response is json (decoded as map) % - Location is create account url % - Nonce is a new valid replay-nonce % % NOTE: tos are automatically agreed, this should not be the case % TODO: checks 201 Created response % -spec account(map(), binary(), map(), map()) -> {ok, map(), binary(), binary()}. account(#{<<"newAccount">> := Uri}, Key, Jws, Opts) -> Payload = #{ termsOfServiceAgreed => true, contact => [] }, Req = letsencrypt_jws:encode(Key, Jws#{url => Uri}, Payload), {ok, #{ json := Resp, location := Location, nonce := Nonce }} = request(post, Uri, #{}, Req, Opts#{json => true}), {ok, Resp, Location, Nonce}. % order(Directory, Domain, Key, Jws, Opts) % % Request new order. % ref: https://www.rfc-editor.org/rfc/rfc8555.html#section-7.4 % % returns: % {ok, Response, Location, Nonce} % - Response is json (decoded as map) % - Location is create account url % - Nonce is a new valid replay-nonce % % TODO: support multiple domains % checks 201 created % -spec order(map(), binary(), binary(), map(), map()) -> {ok, map(), binary(), binary()}. order(#{<<"newOrder">> := Uri}, Domain, Key, Jws, Opts) -> Payload = #{ identifiers => [#{ type => dns, value => Domain }] }, Req = letsencrypt_jws:encode(Key, Jws#{url => Uri}, Payload), {ok, #{ json := Resp, location := Location, nonce := Nonce }} = request(post, Uri, #{}, Req, Opts#{json => true}), {ok, Resp, Location, Nonce}. % order(Uri, Key, Jws, Opts) % % Get order state. % order(Uri, Key, Jws, Opts) -> % POST-as-GET = no payload Req = letsencrypt_jws:encode(Key, Jws#{url => Uri}, empty), {ok, #{ json := Resp, location := Location, nonce := Nonce }} = request(post, Uri, #{}, Req, Opts#{json=> true}), {ok, Resp, Location, Nonce}. % authorization(Uri, Key, Jws, Opts) % % Request authorization for given identifier. % ref: https://www.rfc-editor.org/rfc/rfc8555.html#section-7.4.1 % % returns: % {ok, Response, Location, Nonce} % - Response is json (decoded as map) % - Location is create account url % - Nonce is a new valid replay-nonce % % -spec authorization(binary(), binary(), map(), map()) -> {ok, map(), binary(), binary()}. authorization(Uri, Key, Jws, Opts) -> % POST-as-GET = no payload Req = letsencrypt_jws:encode(Key, Jws#{url => Uri}, empty), {ok, #{ json := Resp, location := Location, nonce := Nonce }} = request(post, Uri, #{}, Req, Opts#{json=> true}), {ok, Resp, Location, Nonce}. % challenge(Challenge, Key, Jws, Opts) % % Notifies acme server we are ready for challenge validation. % ref: https://www.rfc-editor.org/rfc/rfc8555.html#section-7.5.1 % % returns: % {ok, Response, Location, Nonce} % - Response is json (decoded as map) % - Location is create account url % - Nonce is a new valid replay-nonce % -spec challenge(map(), binary(), map(), map()) -> {ok, map(), binary(), binary()}. challenge(#{<<"url">> := Uri}, Key, Jws, Opts) -> % POST-as-GET = no payload Req = letsencrypt_jws:encode(Key, Jws#{url => Uri}, #{}), {ok, #{ json := Resp, location := Location, nonce := Nonce }} = request(post, Uri, #{}, Req, Opts#{json => true}), {ok, Resp, Location, Nonce}. % finalize(Order, Csr, Key, Jws, Opts) % % Finalize order once a challenge has been validated. % ref: https://www.rfc-editor.org/rfc/rfc8555.html#section-7.4 % % returns: % % finalize order -spec finalize(map(), binary(), letsencrypt:ssl_privatekey(), map(), map()) -> {ok, map(), binary(), binary()}. finalize(#{<<"finalize">> := Uri}, Csr, Key, Jws, Opts) -> Payload = #{ csr => Csr }, Req = letsencrypt_jws:encode(Key, Jws#{url => Uri}, Payload), {ok, #{ json := Resp, location := Location, nonce := Nonce }} = request(post, Uri, #{}, Req, Opts#{json => true}), {ok, Resp, Location, Nonce}. % certificate(Order, Key, Jws, Opts) % % Download certificate (for finalized order. % ref: https://www.rfc-editor.org/rfc/rfc8555.html#section-7.4.2 % % returns: % {ok, Cert} % -spec certificate(map(), letsencrypt:ssl_privatekey(), map(), map()) -> {ok, binary()}. certificate(#{<<"certificate">> := Uri}, Key, Jws, Opts) -> % POST-as-GET = no payload Req = letsencrypt_jws:encode(Key, Jws#{url => Uri}, empty), {ok, #{ body := Cert }} = request(post, Uri, #{}, Req, Opts), {ok, Cert}.