Current section
Files
Jump to
Current section
Files
src/letsencrypt.erl
%% 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).
-author("Guillaume Bour <guillaume@bour.cc>").
-behaviour(gen_fsm).
-export([make_cert/2, make_cert_bg/2, get_challenge/0]).
-export([start/1, stop/0, init/1, handle_event/3, handle_sync_event/4, handle_info/3, terminate/3, code_change/4]).
-export([idle/3, pending/3, valid/3, finalize/3]).
-import(letsencrypt_utils, [bin/1, str/1]).
-import(letsencrypt_api, [status/1]).
% uri format compatible with shotgun library
-type mode() :: 'webroot'|'slave'|'standalone'.
% NOTE: currently only support 'http-01' challenge.
-type challenge_type() :: 'http-01'.
-type nonce() :: binary().
-type jws() :: #{'alg' => 'RS256', 'jwk' => map(), nonce => undefined|letsencrypt:nonce() }.
-type ssl_privatekey() :: #{'raw' => crypto:rsa_private(), 'b64' => {binary(), binary()}, 'file' => string()}.
-define(WEBROOT_CHALLENGE_PATH, <<"/.well-known/acme-challenge">>).
-record(state, {
% acme environment
env = prod :: staging | prod,
% acme directory (map operation -> uri)
directory = undefined :: undefined | map(),
% acme_srv = ?DEFAULT_API_URL :: uri() | string(),
key_file = undefined :: undefined | string(),
cert_path = "/tmp" :: string(),
mode = undefined :: undefined | mode(),
% mode = webroot
webroot_path = undefined :: undefined | string(),
% mode = standalone
port = 80 :: integer(),
intermediate_cert = undefined :: undefined | binary(),
% state datas
nonce = undefined :: undefined | nonce(),
domain = undefined :: undefined | binary(),
sans = [] :: list(string()),
key = undefined :: undefined | ssl_privatekey(),
jws = undefined :: undefined | jws(),
account_key = undefined,
order = undefined,
challenges = [] :: map(),
% certificate/csr key file
cert_key_file = undefined,
% api options
opts = #{netopts => #{timeout => 30000}} :: map()
}).
-type state() :: #state{}.
% start(Args).
%
% Starts letsencrypt service.
%
% returns:
% {ok, Pid}
%
-spec start(list()) -> {'ok', pid}|{'error', {'already_started',pid()}}.
start(Args) ->
gen_fsm:start_link({global, ?MODULE}, ?MODULE, Args, []).
% stop().
%
% Stops letsencrypt service.
%
% returns:
% 'ok'
-spec stop() -> 'ok'.
stop() ->
%NOTE: maintain compatibility with 17.X versions
%gen_fsm:stop({global, ?MODULE})
gen_fsm:sync_send_all_state_event({global, ?MODULE}, stop).
%% init(Args).
%%
%% Initialize state machine
%% - init ssl & jws
%% - fetch acme directory
%% - get valid nonce
%%
%% transition:
%% - 'idle' state
-spec init(list( atom() | {atom(),any()} )) -> {ok, idle, state()}.
init(Args) ->
State = setup_mode(
getopts(Args, #state{})
),
%{Args2, State} = mode_opts(proplists:get_value(mode, Args), Args),
%State2 = getopts(Args2, State),
%io:format("state= ~p~n", [State2]),
% initialize key & jws
Key = letsencrypt_ssl:private_key(State#state.key_file, State#state.cert_path),
Jws = letsencrypt_jws:init(Key),
% request directory
{ok, Directory} = letsencrypt_api:directory(State#state.env, State#state.opts),
% get first nonce
{ok, Nonce} = letsencrypt_api:nonce(Directory, State#state.opts),
{ok, idle, State#state{directory=Directory, key=Key, jws=Jws, nonce=Nonce}}.
%%
%% PUBLIC funs
%%
% make_cert(Domain, Opts).
%
% Generates a new certificate for given Domain
%
% params:
% - Domain: domain name to generate acme certificate for
% - Opts : dictionary of options
% * async (bool): if true, make_cert() blocks until complete and returns
% generated certificate filename
% if false, immediately returns
% * callback: function executed when async = true once domain certificate
% has been successfully generated
% returns:
% - 'async' if async is set (default)
% - {error, Err} if something goes bad 😈
%
-spec make_cert(string()|binary(), map()) -> {'ok', #{cert => binary(), key => binary()}}|
{'error','invalid'}|
async.
make_cert(Domain, Opts=#{async := false}) ->
make_cert_bg(Domain, Opts);
% default to async = true
make_cert(Domain, Opts) ->
_Pid = erlang:spawn(?MODULE, make_cert_bg, [Domain, Opts#{async => true}]),
async.
-spec make_cert_bg(string()|binary(), map()) -> {'ok', map()}|{'error', 'invalid'}.
make_cert_bg(Domain, Opts=#{async := Async}) ->
Ret = case gen_fsm:sync_send_event({global, ?MODULE}, {create, bin(Domain), Opts}, 15000) of
{error, Err} ->
io:format("error: ~p~n", [Err]),
{error, Err};
ok ->
case wait_valid(20) of
ok ->
Status = gen_fsm:sync_send_event({global, ?MODULE}, finalize, 15000),
case wait_finalized(Status, 20) of
{ok, Res} -> {ok, Res};
Err -> Err
end;
Error ->
gen_fsm:send_all_state_event({global, ?MODULE}, reset),
Error
end
end,
case Async of
true ->
Callback = maps:get(callback, Opts, fun(_) -> ok end),
Callback(Ret);
_ ->
ok
end,
Ret.
% get_challenge().
%
% Returns ongoing challenges with pre-computed thumbprints.
%
% returns:
% #{Challenge => Thumbrint} if ok,
% 'error' if fails
%
-spec get_challenge() -> error|map().
get_challenge() ->
case catch gen_fsm:sync_send_event({global, ?MODULE}, get_challenge) of
% process not started, wrong state, ...
{'EXIT', _Exc} ->
%io:format("exc: ~p~n", [Exc]),
error;
% challenge #{token => ..., thumbprint => ...}
C -> C
end.
%%
%% gen_server API
%%
% state 'idle'
%
% When awaiting for certificate request.
%
% idle(get_challenge) :: nothing done
%
idle(get_challenge, _, State) ->
{reply, no_challenge, idle, State};
% idle({create, Domain, Opts}).
%
% Starts a new certificate delivery process.
% - create new account
% - create new order (incl
% - requires authorization (returns challenges list)
% - initiate choosen challenge
%
% transition:
% - 'idle' if process failed
% - 'pending' waiting for challenges to be completes
%
idle({create, Domain, _Opts}, _, State=#state{directory=Dir, key=Key, jws=Jws,
nonce=Nonce, opts=Opts}) ->
% 'http-01' or 'tls-sni-01'
% TODO: validate type
ChallengeType = maps:get(challenge, Opts, 'http-01'),
%Conn = get_conn(State),
%Nonce = get_nonce(Conn, State),
%TODO: SANs
%SANs = maps:get(san, Opts, []),
{ok, Accnt, Location, Nonce2} = letsencrypt_api:account(Dir, Key, Jws#{nonce => Nonce}, Opts),
AccntKey = maps:get(<<"key">>, Accnt),
Jws2 = #{
alg => maps:get(alg, Jws),
nonce => Nonce2,
kid => Location
},
%TODO: checks order is ok
{ok, Order, OrderLocation, Nonce3} = letsencrypt_api:order(Dir, bin(Domain), Key, Jws2, Opts),
% we need to keep trace of order location
Order2 = Order#{<<"location">> => OrderLocation},
%Nonce2 = letsencrypt_api:new_reg(Conn, BasePath, Key, JWS#{nonce => Nonce}),
%AuthzResp = authz([Domain|SANs], ChallengeType, State#state{conn=Conn, nonce=Nonce2}),
AuthUris = maps:get(<<"authorizations">>, Order),
AuthzResp = authz(ChallengeType, AuthUris, State#state{domain=Domain, jws=Jws2, account_key=AccntKey, nonce=Nonce3}),
{StateName, Reply, Challenges, Nonce5} = case AuthzResp of
{error, Err, Nonce3} ->
{idle, {error, Err}, nil, Nonce3};
{ok, Xchallenges, Nonce4} ->
{pending, ok, Xchallenges, Nonce4}
end,
{reply, Reply, StateName,
State#state{domain=Domain, jws=Jws2, nonce=Nonce5, order=Order2, challenges=Challenges, sans=[], account_key=AccntKey}}.
% state 'pending'
%
% When challenges are on-the-go.
%
% pending(get_challenge).
%
% Returns list of challenges currently on-the-go with pre-computed thumbprints.
%
pending(get_challenge, _, State=#state{account_key=AccntKey, challenges=Challenges}) ->
% #{Domain => #{
% Token => Thumbprint,
% ...
% }}
%
Thumbprints = maps:from_list(lists:map(
fun(#{<<"token">> := Token}) ->
{Token, letsencrypt_jws:keyauth(AccntKey, Token)}
end, maps:values(Challenges)
)),
{reply, Thumbprints, pending, State};
% pending(check).
%
% Checks if all challenges are completed.
% Switch to 'valid' state iff all challenges are validated only
%
% transition:
% - 'pending' if at least one challenge is not complete yet
% - 'valid' if all challenges are complete
%
%TODO: handle other states explicitely (allowed values are 'invalid', 'deactivated',
% 'expired' and 'revoked'
%
pending(_Action, _, State=#state{order=#{<<"authorizations">> := Authzs}, nonce=Nonce, key=Key, jws=Jws, opts=Opts}) ->
% checking status for each authorization
{StateName, Nonce2} = lists:foldl(fun(AuthzUri, {Status, InNonce}) ->
{ok, Authz, _, OutNonce} = letsencrypt_api:authorization(AuthzUri, Key, Jws#{nonce => InNonce}, Opts),
Status2 = maps:get(<<"status">>, Authz),
%{Status2, Msg2} = letsencrypt_api:challenge(Challengestatus, Conn, UriPath),
%io:format("~p: ~p (~p)~n", [_K, Status2, Msg2]),
Ret = case {Status, Status2} of
{valid , <<"valid">>} -> valid;
{pending, _} -> pending;
{_ , <<"pending">>} -> pending;
%TODO: we must not let that openbar :)
{valid , Status2} -> Status2;
{Status , _} -> Status
end,
{Ret, OutNonce}
end, {valid, Nonce}, Authzs),
%io:format(":: challenge state -> ~p~n", [Reply]),
% reply w/ StateName
{reply, StateName, StateName, State#state{nonce=Nonce2}}.
% state 'valid'
%
% When challenges has been successfully completed.
% Finalize acme order and generate ssl certificate.
%
% returns:
% Status: order status
%
% transition:
% state 'finalize'
valid(_, _, State=#state{mode=Mode, domain=Domain, sans=SANs, cert_path=CertPath,
order=Order, key=Key, jws=Jws, nonce=Nonce, opts=Opts}) ->
challenge_destroy(Mode, State),
%NOTE: keyfile is required for csr generation
#{file := KeyFile} = letsencrypt_ssl:private_key({new, str(Domain) ++ ".key"}, CertPath),
Csr = letsencrypt_ssl:cert_request(str(Domain), CertPath, SANs),
{ok, FinOrder, _, Nonce2} = letsencrypt_api:finalize(Order, Csr, Key,
Jws#{nonce => Nonce}, Opts),
{reply, status(maps:get(<<"status">>, FinOrder, nil)), finalize,
State#state{order=FinOrder#{<<"location">> => maps:get(<<"location">>, Order)},
cert_key_file=KeyFile, nonce=Nonce2}}.
% state 'finalize'
%
% When order is being finalized, and certificate generation is ongoing.
%
% finalize(processing)
%
% Wait for certificate generation being complete (order status == 'valid').
%
% returns:
% Status : order status
%
% transition:
% state 'processing' : still ongoing
% state 'valid' : certificate is ready
finalize(processing, _, State=#state{order=Order, key=Key, jws=Jws, nonce=Nonce, opts=Opts}) ->
{ok, Order2, _, Nonce2} = letsencrypt_api:order(
maps:get(<<"location">>, Order, nil),
Key, Jws#{nonce => Nonce}, Opts),
{reply, status(maps:get(<<"status">>, Order2, nil)), finalize,
State#state{order=Order2, nonce=Nonce2}
};
% finalize(valid)
%
% Download certificate & save into file.
%
% returns;
% #{key, cert}
% - Key is certificate private key filename
% - Cert is certificate PEM filename
%
% transition:
% state 'idle' : fsm complete, going back to initial state
finalize(valid, _, State=#state{order=Order, domain=Domain, cert_key_file=KeyFile,
cert_path=CertPath, key=Key, jws=Jws, nonce=Nonce,
opts=Opts}) ->
% download certificate
{ok, Cert} = letsencrypt_api:certificate(Order, Key, Jws#{nonce => Nonce}, Opts),
CertFile = letsencrypt_ssl:certificate(str(Domain), Cert, CertPath),
{reply, {ok, #{key => bin(KeyFile), cert => bin(CertFile)}}, idle,
State#state{nonce=undefined}};
% finalize(Status)
%
% Any other order status leads to exception.
%
finalize(Status, _, State) ->
io:format("unknown finalize status ~p~n", [Status]),
{reply, {error, Status}, finalize, State}.
%%%
%%%
%%%
handle_event(reset, _StateName, State=#state{mode=Mode}) ->
%io:format("reset from ~p state~n", [StateName]),
challenge_destroy(Mode, State),
{next_state, idle, State};
handle_event(_, StateName, State) ->
io:format("async evt: ~p~n", [StateName]),
{next_state, StateName, State}.
handle_sync_event(stop,_,_,_) ->
{stop, normal, ok, #state{}};
handle_sync_event(_,_, StateName, State) ->
io:format("sync evt: ~p~n", [StateName]),
{reply, ok, StateName, State}.
handle_info(_, StateName, State) ->
{next_state, StateName, State}.
terminate(_,_,_) ->
ok.
code_change(_, StateName, State, _) ->
{ok, StateName, State}.
%%
%% PRIVATE funs
%%
% getopts(Args)
%
% Parse letsencrypt:start() options.
%
% Available options are:
% - staging : runs in staging environment (running on production either)
% - key_file : reuse an existing ssl key
% - cert_path : path to read/save ssl certificate, key and csr request
% - connect_timeout: timeout for acme api requests (seconds)
% THIS OPTION IS DEPRECATED, REPLACED BY http_timeout
% - http_timeout : timeout for acme api requests (seconds)
%
% returns:
% - State (type record 'state') filled with options values
%
% exception:
% - 'badarg' if unrecognized option
%
-spec getopts(list(atom()|{atom(),any()}), state()) -> state().
getopts([], State) ->
State;
getopts([staging|Args], State) ->
getopts(
Args,
State#state{env = staging}
);
getopts([{mode, Mode}|Args], State) ->
getopts(
Args,
State#state{mode=Mode}
);
getopts([{key_file, KeyFile}|Args], State) ->
getopts(
Args,
State#state{key_file = KeyFile}
);
getopts([{cert_path, Path}|Args], State) ->
getopts(
Args,
State#state{cert_path = Path}
);
getopts([{webroot_path, Path}|Args], State) ->
getopts(
Args,
State#state{webroot_path = Path}
);
getopts([{port, Port}|Args], State) ->
getopts(
Args,
State#state{port = Port}
);
% for compatibility. Will be removed in future release
getopts([{connect_timeout, Timeout}|Args], State) ->
io:format("'connect_timeout' option is deprecated. Please use 'http_timeout' instead~n", []),
getopts(
Args,
State#state{opts = #{netopts => #{timeout => Timeout}}}
);
getopts([{http_timeout, Timeout}|Args], State) ->
getopts(
Args,
State#state{opts = #{netopts => #{timeout => Timeout}}}
);
getopts([Unk|_], _) ->
io:format("unknow parameter: ~p~n", [Unk]),
%throw({badarg, io_lib:format("unknown ~p parameter", [Unk])}).
throw(badarg).
% setup_mode(State).
%
% Setup context of choosen mode.
%
% returns:
% - State, as received
%
% exception:
% - 'misarg' if a parameter is missing for choosen mode,
% - 'invalid_mode' if mode is not valid
%
-spec setup_mode(state()) -> state().
setup_mode(#state{mode=webroot, webroot_path=undefined}) ->
io:format("missing 'webroot_path' parameter", []),
throw(misarg);
setup_mode(State=#state{mode=webroot, webroot_path=Path}) ->
%TODO: check directory is writeable
%TODO: handle errors
%TODO: protect against injections ?
os:cmd(string:join(["mkdir -p '", Path, str(?WEBROOT_CHALLENGE_PATH), "'"], "")),
State;
setup_mode(State=#state{mode=standalone, port=_Port}) ->
%TODO: checking port is unused ?
State;
setup_mode(State=#state{mode=slave}) ->
State;
% every other mode value is invalid
setup_mode(#state{mode=Mode}) ->
io:format("invalid '~p' mode", [Mode]),
throw(invalid_mode).
% wait_valid(X).
%
% Loops X time on authorization check until challenges are all validated
% (waits incrementing time between each trial).
%
% returns:
% - {error, timeout} if failed after X loops
% - {error, Err} if another error
% - 'ok' if succeed
%
-spec wait_valid(0..10) -> ok|{error, any()}.
wait_valid(X) ->
wait_valid(X,X).
-spec wait_valid(0..10, 0..10) -> ok|{error, any()}.
wait_valid(0,_) ->
{error, timeout};
wait_valid(Cnt,Max) ->
case gen_fsm:sync_send_event({global, ?MODULE}, check, 15000) of
valid -> ok;
pending ->
timer:sleep(500*(Max-Cnt+1)),
wait_valid(Cnt-1,Max);
{_ , Err} -> {error, Err}
end.
% wait_finalized(X).
%
% Loops X time on order being finalized
% (waits incrementing time between each trial).
%
% returns:
% - {error, timeout} if failed after X loops
% - {error, Err} if another error
% - {'ok', Response} if succeed
%
-spec wait_finalized(atom(), 0..10) -> {ok, map()}|{error, timeout|any()}.
wait_finalized(Status, X) ->
wait_finalized(Status,X,X).
-spec wait_finalized(atom(), 0..10, 0..10) -> {ok, map()}|{error, timeout|any()}.
wait_finalized(_, 0,_) ->
{error, timeout};
wait_finalized(Status, Cnt,Max) ->
case gen_fsm:sync_send_event({global, ?MODULE}, Status, 15000) of
{ok, Res} -> {ok, Res};
valid ->
timer:sleep(500*(Max-Cnt+1)),
wait_finalized(valid, Cnt-1,Max);
processing ->
timer:sleep(500*(Max-Cnt+1)),
wait_finalized(processing, Cnt-1,Max);
{_ , Err} -> {error, Err};
Any -> Any
end.
% authz(ChallenteType, AuthzUris, State).
%
% Perform acme authorization and selected challenge initialization.
%
% returns:
% {ok, Challenges, Nonce}
% {error, Error, Nonce}
%
-spec authz(challenge_type(), list(binary()), state()) -> {error, uncatched|binary(), nonce()}|
{ok, map(), nonce()}.
authz(ChallengeType, AuthzUris, State=#state{mode=Mode}) ->
case authz_step1(AuthzUris, ChallengeType, State, #{}) of
{error, Err, Nonce} ->
{error, Err, Nonce};
{ok, Challenges, Nonce2} ->
%io:format("challenges: ~p ~p~n", [Challenges, Domain]),
challenge_init(Mode, State, ChallengeType, Challenges),
case authz_step2(maps:to_list(Challenges), State#state{nonce=Nonce2}) of
{ok, Nonce3} ->
{ok, Challenges, Nonce3};
Err ->
Err
end
end.
% authz_step1(AuthzUris, ChallengeType, State).
%
% Request authorizations.
%
% returns:
% {ok, Challenges, Nonce}
% - Challenges is map of Uri -> Challenge, where Challenge is of ChallengeType type
% - Nonce is a new valid replay-nonce
%
-spec authz_step1(list(binary()), challenge_type(), state(), map()) -> {ok, map(), nonce()} |
{error, uncatched|binary(), nonce()}.
authz_step1([], _, #state{nonce=Nonce}, Challenges) ->
{ok, Challenges, Nonce};
authz_step1([Uri|T], ChallengeType,
State=#state{nonce=Nonce, key=Key, jws=Jws, opts=Opts}, Challenges) ->
AuthzRet = letsencrypt_api:authorization(Uri, Key, Jws#{nonce => Nonce}, Opts),
%io:format("authzret= ~p~n", [AuthzRet]),
case AuthzRet of
%{error, Err, Nonce2} ->
% {error, Err, Nonce2};
{ok, Authz, _, Nonce2} ->
% get challenge
% map(
% type,
% url,
% token
% )
[Challenge] = lists:filter(fun(C) ->
maps:get(<<"type">>, C, error) =:= bin(ChallengeType)
end,
maps:get(<<"challenges">>, Authz)
),
authz_step1(T, ChallengeType, State#state{nonce=Nonce2},
Challenges#{Uri => Challenge})
end.
% authz_step2(Challenges, State).
%
% 2d part Authorization, executed after challenge initialization.
% Notify acme server we're good to proceed to challenges.
%
-spec authz_step2(list(binary()), state()) -> {ok, nonce()} | {error, binary(), nonce()}.
authz_step2([], #state{nonce=Nonce}) ->
{ok, Nonce};
authz_step2([{_Uri, Challenge}|T], State=#state{nonce=Nonce, key=Key, jws=Jws, opts=Opts}) ->
{ok, _, _, Nonce2 } = letsencrypt_api:challenge(Challenge, Key, Jws#{nonce => Nonce}, Opts),
authz_step2(T, State#state{nonce=Nonce2}).
% challenge_init(Mode, State, ChallengeType, Challenges)
%
% Initialize local configuration to serve given challenge
% Depends on challenge type & mode
%
% TODO: ChallengeType is included in Challenges (<<"type">> key). To refactor
%
-spec challenge_init(mode(), state(), challenge_type(), map()) -> ok.
challenge_init(webroot, #state{webroot_path=WPath, account_key=AccntKey}, 'http-01', Challenges) ->
maps:fold(
fun(_K, #{<<"token">> := Token}, _Acc) ->
Thumbprint = letsencrypt_jws:keyauth(AccntKey, Token),
file:write_file(<<(bin(WPath))/binary, $/, ?WEBROOT_CHALLENGE_PATH/binary, $/, Token/binary>>,
Thumbprint)
end,
0, Challenges
);
challenge_init(slave, _, _, _) ->
ok;
challenge_init(standalone, #state{port=Port, domain=Domain, account_key=AccntKey}, ChallengeType, Challenges) ->
%io:format("init standalone challenge for ~p~n", [ChallengeType]),
{ok, _} = case ChallengeType of
'http-01' ->
% elli webserver callback args is:
% #{Domain => #{
% Token => Thumbprint,
% ...
% }}
%
Thumbprints = maps:from_list(lists:map(
fun(#{<<"token">> := Token}) ->
{Token, letsencrypt_jws:keyauth(AccntKey, Token)}
end, maps:values(Challenges)
)),
elli:start_link([
{name , {local, letsencrypt_elli_listener}},
{callback, letsencrypt_elli_handler},
{callback_args, [#{Domain => Thumbprints}]},
{port , Port}
]);
_ ->
io:format("standalone mode: unknown ~p challenge type~n", [ChallengeType])
% TODO
%'tls-sni-01' ->
end,
ok.
% challenge_destroy(Mode, State)
%
% cleanup challenge context after it has been fullfilled (with success or not).
% 'webroot' mode:
% - delete token file
% 'standalone' mode:
% - stop internal webserver
% 'slave' mode:
% - _nothing to do_
%
% returns: 'ok'
%
-spec challenge_destroy(mode(), state()) -> ok.
challenge_destroy(webroot, #state{webroot_path=WPath, challenges=Challenges}) ->
maps:fold(fun(_K, #{<<"token">> := Token}, _) ->
file:delete(<<(bin(WPath))/binary, $/, ?WEBROOT_CHALLENGE_PATH/binary, $/, Token/binary>>)
end, 0, Challenges),
ok;
challenge_destroy(standalone, _) ->
% stop http server
elli:stop(letsencrypt_elli_listener),
ok;
challenge_destroy(slave, _) ->
ok.