mirror of
https://github.com/processone/ejabberd.git
synced 2024-11-26 16:26:24 +01:00
1235 lines
39 KiB
Erlang
1235 lines
39 KiB
Erlang
-module (ejabberd_acme).
|
|
-behaviour(gen_server).
|
|
-behaviour(ejabberd_config).
|
|
|
|
%% ejabberdctl commands
|
|
-export([get_certificates/1,
|
|
renew_certificates/0,
|
|
list_certificates/1,
|
|
revoke_certificate/1]).
|
|
%% Command Options Validity
|
|
-export([is_valid_account_opt/1,
|
|
is_valid_verbose_opt/1,
|
|
is_valid_domain_opt/1,
|
|
is_valid_revoke_cert/1]).
|
|
%% Key Related
|
|
-export([generate_key/0, to_public/1]).
|
|
%% gen_server callbacks
|
|
-export([init/1, handle_call/3, handle_cast/2, handle_info/2,
|
|
terminate/2, code_change/3]).
|
|
-export([start_link/0, opt_type/1, register_certfiles/0]).
|
|
|
|
-include("ejabberd.hrl").
|
|
-include("logger.hrl").
|
|
-include("xmpp.hrl").
|
|
-include("ejabberd_commands.hrl").
|
|
-include("ejabberd_acme.hrl").
|
|
-include_lib("public_key/include/public_key.hrl").
|
|
|
|
-define(DEFAULT_CONFIG_CONTACT, <<"mailto:example-admin@example.com">>).
|
|
-define(DEFAULT_CONFIG_CA_URL, "https://acme-v01.api.letsencrypt.org").
|
|
|
|
-record(state, {}).
|
|
|
|
start_link() ->
|
|
gen_server:start_link({local, ?MODULE}, ?MODULE, [], []).
|
|
|
|
%%%===================================================================
|
|
%%% gen_server callbacks
|
|
%%%===================================================================
|
|
init([]) ->
|
|
case filelib:ensure_dir(filename:join(acme_certs_dir(), "foo")) of
|
|
ok ->
|
|
ejabberd_hooks:add(config_reloaded, ?MODULE, register_certfiles, 40),
|
|
ejabberd_commands:register_commands(get_commands_spec()),
|
|
register_certfiles(),
|
|
{ok, #state{}};
|
|
{error, Why} ->
|
|
?CRITICAL_MSG("Failed to create directory ~s: ~s",
|
|
[acme_certs_dir(), file:format_error(Why)]),
|
|
{stop, Why}
|
|
end.
|
|
|
|
handle_call(_Request, _From, State) ->
|
|
{stop, {unexpected_call, _Request, _From}, State}.
|
|
|
|
handle_cast(_Msg, State) ->
|
|
?WARNING_MSG("unexpected cast: ~p", [_Msg]),
|
|
{noreply, State}.
|
|
|
|
handle_info(_Info, State) ->
|
|
?WARNING_MSG("unexpected info: ~p", [_Info]),
|
|
{noreply, State}.
|
|
|
|
terminate(_Reason, _State) ->
|
|
ejabberd_hooks:delete(config_reloaded, ?MODULE, register_certfiles, 40),
|
|
ejabberd_commands:unregister_commands(get_commands_spec()).
|
|
|
|
code_change(_OldVsn, State, _Extra) ->
|
|
{ok, State}.
|
|
|
|
%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%
|
|
%%
|
|
%% 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.
|
|
|
|
%% TODO: Make this check more complicated
|
|
-spec is_valid_domain_opt(string()) -> boolean().
|
|
is_valid_domain_opt("all") -> true;
|
|
is_valid_domain_opt(DomainString) ->
|
|
case parse_domain_string(DomainString) of
|
|
[] ->
|
|
false;
|
|
_SeparatedDomains ->
|
|
true
|
|
end.
|
|
|
|
-spec is_valid_revoke_cert(string()) -> boolean().
|
|
is_valid_revoke_cert(DomainOrFile) ->
|
|
lists:prefix("file:", DomainOrFile) orelse
|
|
lists:prefix("domain:", DomainOrFile).
|
|
|
|
%% Commands
|
|
get_commands_spec() ->
|
|
[#ejabberd_commands{name = get_certificates, tags = [acme],
|
|
desc = "Gets certificates for all or the specified "
|
|
"domains {all|domain1;domain2;...}.",
|
|
module = ?MODULE, function = get_certificates,
|
|
args_desc = ["Domains for which to acquire a certificate"],
|
|
args_example = ["all | www.example.com;www.example1.net"],
|
|
args = [{domains, string}],
|
|
result = {certificates, string}},
|
|
#ejabberd_commands{name = renew_certificates, tags = [acme],
|
|
desc = "Renews all certificates that are close to expiring",
|
|
module = ?MODULE, function = renew_certificates,
|
|
args = [],
|
|
result = {certificates, string}},
|
|
#ejabberd_commands{name = list_certificates, tags = [acme],
|
|
desc = "Lists all curently handled certificates and "
|
|
"their respective domains in {plain|verbose} format",
|
|
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 = revoke_certificate, tags = [acme],
|
|
desc = "Revokes the selected certificate",
|
|
module = ?MODULE, function = revoke_certificate,
|
|
args_desc = ["The domain or file (in pem format) of "
|
|
"the certificate in question "
|
|
"{domain:Domain | file:File}"],
|
|
args = [{domain_or_file, string}],
|
|
result = {res, restuple}}].
|
|
|
|
%%
|
|
%% Get Certificate
|
|
%%
|
|
-spec get_certificates(domains_opt()) -> string() | {'error', _}.
|
|
get_certificates(Domains) ->
|
|
case is_valid_domain_opt(Domains) of
|
|
true ->
|
|
try
|
|
CAUrl = get_config_ca_url(),
|
|
get_certificates0(CAUrl, Domains)
|
|
catch
|
|
throw:Throw ->
|
|
Throw;
|
|
E:R ->
|
|
?ERROR_MSG("Unknown ~p:~p, ~p", [E, R, erlang:get_stacktrace()]),
|
|
{error, get_certificates}
|
|
end;
|
|
false ->
|
|
io_lib:format("Invalid domains: ~p", [Domains])
|
|
end.
|
|
|
|
-spec get_certificates0(url(), domains_opt()) -> string().
|
|
get_certificates0(CAUrl, Domains) ->
|
|
%% Check if an account exists or create another one
|
|
{ok, _AccId, PrivateKey} = retrieve_or_create_account(CAUrl),
|
|
|
|
get_certificates1(CAUrl, Domains, PrivateKey).
|
|
|
|
-spec retrieve_or_create_account(url()) -> {'ok', string(), jose_jwk:key()}.
|
|
retrieve_or_create_account(CAUrl) ->
|
|
case read_account_persistent() of
|
|
none ->
|
|
create_save_new_account(CAUrl);
|
|
|
|
{ok, AccId, CAUrl, PrivateKey} ->
|
|
{ok, AccId, PrivateKey};
|
|
{ok, _AccId, _, _PrivateKey} ->
|
|
create_save_new_account(CAUrl)
|
|
end.
|
|
|
|
|
|
-spec get_certificates1(url(), domains_opt(), jose_jwk:key()) -> string().
|
|
get_certificates1(CAUrl, "all", PrivateKey) ->
|
|
Hosts = get_config_hosts(),
|
|
get_certificates2(CAUrl, PrivateKey, Hosts);
|
|
get_certificates1(CAUrl, DomainString, PrivateKey) ->
|
|
Domains = parse_domain_string(DomainString),
|
|
Hosts = [list_to_bitstring(D) || D <- Domains],
|
|
get_certificates2(CAUrl, PrivateKey, Hosts).
|
|
|
|
-spec get_certificates2(url(), jose_jwk:key(), [bitstring()]) -> string().
|
|
get_certificates2(CAUrl, PrivateKey, 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
|
|
format_get_certificates_result(SavedCerts).
|
|
|
|
-spec format_get_certificates_result([{'ok', bitstring(), _} |
|
|
{'error', bitstring(), _}]) ->
|
|
string().
|
|
format_get_certificates_result(Certs) ->
|
|
Cond = lists:all(fun(Cert) ->
|
|
not is_error(Cert)
|
|
end, Certs),
|
|
%% FormattedCerts = string:join([format_get_certificate(C) || C <- Certs], "\n"),
|
|
FormattedCerts = str:join([format_get_certificate(C) || C <- Certs], $\n),
|
|
case Cond of
|
|
true ->
|
|
Result = io_lib:format("Success:~n~s", [FormattedCerts]),
|
|
lists:flatten(Result);
|
|
_ ->
|
|
Result = io_lib:format("Error with one or more certificates~n~s", [FormattedCerts]),
|
|
lists:flatten(Result)
|
|
end.
|
|
|
|
-spec format_get_certificate({'ok', bitstring(), _} |
|
|
{'error', bitstring(), _}) ->
|
|
string().
|
|
format_get_certificate({ok, Domain, saved}) ->
|
|
io_lib:format(" Certificate for domain: \"~s\" acquired and saved", [Domain]);
|
|
format_get_certificate({ok, Domain, not_found}) ->
|
|
io_lib:format(" Certificate for domain: \"~s\" not found, so it was not renewed", [Domain]);
|
|
format_get_certificate({ok, Domain, no_expire}) ->
|
|
io_lib:format(" Certificate for domain: \"~s\" is not close to expiring", [Domain]);
|
|
format_get_certificate({error, Domain, Reason}) ->
|
|
io_lib:format(" Error for domain: \"~s\", with reason: \'~s\'", [Domain, Reason]).
|
|
|
|
-spec get_certificate(url(), bitstring(), jose_jwk:key()) ->
|
|
{'ok', bitstring(), pem()} |
|
|
{'error', bitstring(), _}.
|
|
get_certificate(CAUrl, DomainName, PrivateKey) ->
|
|
try
|
|
AllSubDomains = find_all_sub_domains(DomainName),
|
|
lists:foreach(
|
|
fun(Domain) ->
|
|
{ok, _Authz} = create_new_authorization(CAUrl, Domain, PrivateKey)
|
|
end, [DomainName|AllSubDomains]),
|
|
create_new_certificate(CAUrl, {DomainName, AllSubDomains}, 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
|
|
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, CAUrl, 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) ->
|
|
acme_challenge:register_hooks(DomainName),
|
|
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})
|
|
after
|
|
acme_challenge:unregister_hooks(DomainName)
|
|
end.
|
|
|
|
-spec create_new_certificate(url(), {bitstring(), [bitstring()]}, jose_jwk:key()) ->
|
|
{ok, bitstring(), pem()}.
|
|
create_new_certificate(CAUrl, {DomainName, AllSubDomains}, PrivateKey) ->
|
|
try
|
|
{ok, Dirs, Nonce0} = ejabberd_acme_comm:directory(CAUrl),
|
|
CSRSubject = [{commonName, bitstring_to_list(DomainName)}],
|
|
SANs = [{dNSName, SAN} || SAN <- AllSubDomains],
|
|
{CSR, CSRKey} = make_csr(CSRSubject, SANs),
|
|
{NotBefore, NotAfter} = not_before_not_after(),
|
|
Req =
|
|
[{<<"csr">>, CSR},
|
|
{<<"notBefore">>, NotBefore},
|
|
{<<"NotAfter">>, NotAfter}
|
|
],
|
|
{ok, {IssuerCertLink, 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),
|
|
|
|
{ok, IssuerCert, _Nonce2} = ejabberd_acme_comm:get_issuer_cert(IssuerCertLink),
|
|
DecodedIssuerCert = public_key:pkix_decode_cert(list_to_binary(IssuerCert), plain),
|
|
PemEntryIssuerCert = public_key:pem_entry_encode('Certificate', DecodedIssuerCert),
|
|
|
|
{_, CSRKeyKey} = jose_jwk:to_key(CSRKey),
|
|
PemEntryKey = public_key:pem_entry_encode('ECPrivateKey', CSRKeyKey),
|
|
|
|
PemCertKey = public_key:pem_encode([PemEntryKey, PemEntryCert, PemEntryIssuerCert]),
|
|
|
|
{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.
|
|
|
|
-spec ensure_account_exists(url()) -> {ok, string(), jose_jwk:key()}.
|
|
ensure_account_exists(CAUrl) ->
|
|
case read_account_persistent() of
|
|
none ->
|
|
?ERROR_MSG("No existing account", []),
|
|
throw({error, no_old_account});
|
|
{ok, AccId, CAUrl, PrivateKey} ->
|
|
{ok, AccId, PrivateKey};
|
|
{ok, _AccId, OtherCAUrl, _PrivateKey} ->
|
|
?ERROR_MSG("Account is connected to another CA: ~s", [OtherCAUrl]),
|
|
throw({error, account_in_other_CA})
|
|
end.
|
|
|
|
|
|
%%
|
|
%% Renew Certificates
|
|
%%
|
|
-spec renew_certificates() -> string() | {'error', _}.
|
|
renew_certificates() ->
|
|
try
|
|
CAUrl = get_config_ca_url(),
|
|
renew_certificates0(CAUrl)
|
|
catch
|
|
throw:Throw ->
|
|
Throw;
|
|
E:R ->
|
|
?ERROR_MSG("Unknown ~p:~p, ~p", [E, R, erlang:get_stacktrace()]),
|
|
{error, get_certificates}
|
|
end.
|
|
|
|
-spec renew_certificates0(url()) -> string().
|
|
renew_certificates0(CAUrl) ->
|
|
%% Get the current account
|
|
{ok, _AccId, PrivateKey} = ensure_account_exists(CAUrl),
|
|
|
|
%% Find all hosts that we have certificates for
|
|
Certs = read_certificates_persistent(),
|
|
|
|
%% Get a certificate for each host
|
|
PemCertKeys = [renew_certificate(CAUrl, Cert, PrivateKey) || Cert <- Certs],
|
|
|
|
%% Save Certificates
|
|
SavedCerts = [save_renewed_certificate(Cert) || Cert <- PemCertKeys],
|
|
|
|
%% Format the result to send back to ejabberdctl
|
|
format_get_certificates_result(SavedCerts).
|
|
|
|
-spec renew_certificate(url(), {bitstring(), data_cert()}, jose_jwk:key()) ->
|
|
{'ok', bitstring(), _} |
|
|
{'error', bitstring(), _}.
|
|
renew_certificate(CAUrl, {DomainName, _} = Cert, PrivateKey) ->
|
|
case cert_to_expire(Cert) of
|
|
true ->
|
|
get_certificate(CAUrl, DomainName, PrivateKey);
|
|
false ->
|
|
{ok, DomainName, no_expire}
|
|
end.
|
|
|
|
|
|
-spec cert_to_expire({bitstring(), data_cert()}) -> boolean().
|
|
cert_to_expire({_DomainName, #data_cert{pem = Pem}}) ->
|
|
Certificate = pem_to_certificate(Pem),
|
|
Validity = get_utc_validity(Certificate),
|
|
|
|
%% 30 days before expiration
|
|
close_to_expire(Validity, 30).
|
|
|
|
-spec close_to_expire(string(), integer()) -> boolean().
|
|
close_to_expire(Validity, Days) ->
|
|
{ValidDate, _ValidTime} = utc_string_to_datetime(Validity),
|
|
ValidDays = calendar:date_to_gregorian_days(ValidDate),
|
|
|
|
{CurrentDate, _CurrentTime} = calendar:universal_time(),
|
|
CurrentDays = calendar:date_to_gregorian_days(CurrentDate),
|
|
CurrentDays > ValidDays - Days.
|
|
|
|
|
|
|
|
%%
|
|
%% List Certificates
|
|
%%
|
|
-spec list_certificates(verbose_opt()) -> [string()] | [any()] | {error, _}.
|
|
list_certificates(Verbose) ->
|
|
case is_valid_verbose_opt(Verbose) of
|
|
true ->
|
|
try
|
|
list_certificates0(Verbose)
|
|
catch
|
|
throw:Throw ->
|
|
Throw;
|
|
E:R ->
|
|
?ERROR_MSG("Unknown ~p:~p, ~p", [E, R, erlang:get_stacktrace()]),
|
|
{error, list_certificates}
|
|
end;
|
|
false ->
|
|
String = io_lib:format("Invalid verbose option: ~p", [Verbose]),
|
|
{invalid_option, String}
|
|
end.
|
|
|
|
-spec list_certificates0(verbose_opt()) -> [string()] | [any()].
|
|
list_certificates0(Verbose) ->
|
|
Certs = read_certificates_persistent(),
|
|
[format_certificate(DataCert, Verbose) || {_Key, DataCert} <- Certs].
|
|
|
|
%% TODO: Make this cleaner and more robust
|
|
-spec format_certificate(data_cert(), verbose_opt()) -> string().
|
|
format_certificate(DataCert, Verbose) ->
|
|
#data_cert{
|
|
domain = DomainName,
|
|
pem = PemCert,
|
|
path = Path
|
|
} = DataCert,
|
|
|
|
try
|
|
Certificate = pem_to_certificate(PemCert),
|
|
|
|
%% Find the commonName
|
|
_CommonName = get_commonName(Certificate),
|
|
|
|
%% Find the notAfter date
|
|
NotAfter = get_notAfter(Certificate),
|
|
|
|
%% Find the subjectAltNames
|
|
SANs = get_subjectAltNames(Certificate),
|
|
|
|
case Verbose of
|
|
"plain" ->
|
|
format_certificate_plain(DomainName, SANs, NotAfter, Path);
|
|
"verbose" ->
|
|
format_certificate_verbose(DomainName, SANs, NotAfter, PemCert)
|
|
end
|
|
catch
|
|
E:R ->
|
|
?ERROR_MSG("Unknown ~p:~p, ~p", [E, R, erlang:get_stacktrace()]),
|
|
fail_format_certificate(DomainName)
|
|
end.
|
|
|
|
-spec format_certificate_plain(bitstring(), [string()], {expired | ok, string()}, string())
|
|
-> string().
|
|
format_certificate_plain(DomainName, SANs, NotAfter, Path) ->
|
|
Result = lists:flatten(io_lib:format(
|
|
" Domain: ~s~n"
|
|
"~s"
|
|
" ~s~n"
|
|
" Path: ~s",
|
|
[DomainName,
|
|
lists:flatten([io_lib:format(" SAN: ~s~n", [SAN]) || SAN <- SANs]),
|
|
format_validity(NotAfter), Path])),
|
|
Result.
|
|
|
|
-spec format_certificate_verbose(bitstring(), [string()], {expired | ok, string()}, bitstring())
|
|
-> string().
|
|
format_certificate_verbose(DomainName, SANs, NotAfter, PemCert) ->
|
|
Result = lists:flatten(io_lib:format(
|
|
" Domain: ~s~n"
|
|
"~s"
|
|
" ~s~n"
|
|
" Certificate In PEM format: ~n~s",
|
|
[DomainName,
|
|
lists:flatten([io_lib:format(" SAN: ~s~n", [SAN]) || SAN <- SANs]),
|
|
format_validity(NotAfter), PemCert])),
|
|
Result.
|
|
|
|
-spec format_validity({'expired' | 'ok', string()}) -> string().
|
|
format_validity({expired, NotAfter}) ->
|
|
io_lib:format("Expired at: ~s UTC", [NotAfter]);
|
|
format_validity({ok, NotAfter}) ->
|
|
io_lib:format("Valid until: ~s UTC", [NotAfter]).
|
|
|
|
-spec fail_format_certificate(bitstring()) -> string().
|
|
fail_format_certificate(DomainName) ->
|
|
Result = lists:flatten(io_lib:format(
|
|
" Domain: ~s~n"
|
|
" Failed to format Certificate",
|
|
[DomainName])),
|
|
Result.
|
|
|
|
-spec get_commonName(#'Certificate'{}) -> string().
|
|
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.
|
|
|
|
-spec get_notAfter(#'Certificate'{}) -> {expired | ok, string()}.
|
|
get_notAfter(Certificate) ->
|
|
UtcTime = get_utc_validity(Certificate),
|
|
%% TODO: Find a library function to decode utc time
|
|
[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]])),
|
|
|
|
case close_to_expire(UtcTime, 0) of
|
|
true ->
|
|
{expired, NotAfter};
|
|
false ->
|
|
{ok, NotAfter}
|
|
end.
|
|
|
|
-spec get_subjectAltNames(#'Certificate'{}) -> [string()].
|
|
get_subjectAltNames(#'Certificate'{tbsCertificate = TbsCertificate}) ->
|
|
#'TBSCertificate'{
|
|
extensions = Exts
|
|
} = TbsCertificate,
|
|
|
|
EncodedSANs = [Val || #'Extension'{extnID = Oid, extnValue = Val} <- Exts,
|
|
Oid =:= attribute_oid(subjectAltName)],
|
|
|
|
lists:flatmap(
|
|
fun(EncSAN) ->
|
|
SANs0 = public_key:der_decode('SubjectAltName', EncSAN),
|
|
[Name || {dNSName, Name} <- SANs0]
|
|
end, EncodedSANs).
|
|
|
|
|
|
|
|
-spec get_utc_validity(#'Certificate'{}) -> string().
|
|
get_utc_validity(#'Certificate'{tbsCertificate = TbsCertificate}) ->
|
|
#'TBSCertificate'{
|
|
validity = Validity
|
|
} = TbsCertificate,
|
|
|
|
#'Validity'{notAfter = {utcTime, UtcTime}} = Validity,
|
|
UtcTime.
|
|
|
|
%%
|
|
%% Revoke Certificate
|
|
%%
|
|
|
|
revoke_certificate(DomainOrFile) ->
|
|
case is_valid_revoke_cert(DomainOrFile) of
|
|
true ->
|
|
revoke_certificates(DomainOrFile);
|
|
false ->
|
|
String = io_lib:format("Bad argument: ~s", [DomainOrFile]),
|
|
{invalid_argument, String}
|
|
end.
|
|
|
|
-spec revoke_certificates(string()) -> {ok, deleted} | {error, _}.
|
|
revoke_certificates(DomainOrFile) ->
|
|
try
|
|
CAUrl = get_config_ca_url(),
|
|
revoke_certificate0(CAUrl, DomainOrFile)
|
|
catch
|
|
throw:Throw ->
|
|
Throw;
|
|
E:R ->
|
|
?ERROR_MSG("Unknown ~p:~p, ~p", [E, R, erlang:get_stacktrace()]),
|
|
{error, revoke_certificate}
|
|
end.
|
|
|
|
-spec revoke_certificate0(url(), string()) -> {ok, deleted}.
|
|
revoke_certificate0(CAUrl, DomainOrFile) ->
|
|
ParsedCert = parse_revoke_cert_argument(DomainOrFile),
|
|
revoke_certificate1(CAUrl, ParsedCert).
|
|
|
|
-spec revoke_certificate1(url(), {domain, bitstring()} | {file, file:filename()}) ->
|
|
{ok, deleted}.
|
|
revoke_certificate1(CAUrl, {domain, Domain}) ->
|
|
case domain_certificate_exists(Domain) of
|
|
{Domain, Cert = #data_cert{pem=PemCert}} ->
|
|
ok = revoke_certificate2(CAUrl, PemCert),
|
|
ok = remove_certificate_persistent(Cert),
|
|
{ok, deleted};
|
|
false ->
|
|
?ERROR_MSG("Certificate for domain: ~p not found", [Domain]),
|
|
throw({error, not_found})
|
|
end;
|
|
revoke_certificate1(CAUrl, {file, File}) ->
|
|
case file:read_file(File) of
|
|
{ok, Pem} ->
|
|
ok = revoke_certificate2(CAUrl, Pem),
|
|
{ok, deleted};
|
|
{error, Reason} ->
|
|
?ERROR_MSG("Error: ~p reading pem certificate-key file: ~p", [Reason, File]),
|
|
throw({error, Reason})
|
|
end.
|
|
|
|
|
|
-spec revoke_certificate2(url(), pem()) -> ok.
|
|
revoke_certificate2(CAUrl, PemEncodedCert) ->
|
|
{Certificate, CertPrivateKey} = prepare_certificate_revoke(PemEncodedCert),
|
|
|
|
{ok, Dirs, Nonce} = ejabberd_acme_comm:directory(CAUrl),
|
|
|
|
Req = [{<<"certificate">>, Certificate}],
|
|
{ok, [], _Nonce1} = ejabberd_acme_comm:revoke_cert(Dirs, CertPrivateKey, Req, Nonce),
|
|
ok.
|
|
|
|
-spec parse_revoke_cert_argument(string()) -> {domain, bitstring()} | {file, file:filename()}.
|
|
parse_revoke_cert_argument([$f, $i, $l, $e, $:|File]) ->
|
|
{file, File};
|
|
parse_revoke_cert_argument([$d, $o, $m, $a, $i, $n, $: | Domain]) ->
|
|
{domain, list_to_bitstring(Domain)}.
|
|
|
|
-spec prepare_certificate_revoke(pem()) -> {bitstring(), jose_jwk:key()}.
|
|
prepare_certificate_revoke(PemEncodedCert) ->
|
|
PemList = public_key:pem_decode(PemEncodedCert),
|
|
PemCertEnc = lists:keyfind('Certificate', 1, PemList),
|
|
PemCert = public_key:pem_entry_decode(PemCertEnc),
|
|
DerCert = public_key:der_encode('Certificate', PemCert),
|
|
Base64Cert = base64url:encode(DerCert),
|
|
|
|
{ok, Key} = find_private_key_in_pem(PemEncodedCert),
|
|
{Base64Cert, Key}.
|
|
|
|
-spec domain_certificate_exists(bitstring()) -> {bitstring(), data_cert()} | false.
|
|
domain_certificate_exists(Domain) ->
|
|
Certs = read_certificates_persistent(),
|
|
lists:keyfind(Domain, 1, Certs).
|
|
|
|
%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%
|
|
%%
|
|
%% Certificate Request Functions
|
|
%%
|
|
%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%
|
|
|
|
%% For now we accept only generating a key of
|
|
%% specific type for signing the csr
|
|
|
|
-spec make_csr(proplist(), [{dNSName, bitstring()}])
|
|
-> {binary(), jose_jwk:key()}.
|
|
make_csr(Attributes, SANs) ->
|
|
Key = generate_key(),
|
|
{_, KeyKey} = jose_jwk:to_key(Key),
|
|
KeyPub = 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),
|
|
ExtensionRequest = extension_request(SANs),
|
|
CRI = certificate_request_info(SubPKInfo, Subject, ExtensionRequest),
|
|
{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
|
|
}.
|
|
|
|
extension(SANs) ->
|
|
#'Extension'{
|
|
extnID = attribute_oid(subjectAltName),
|
|
critical = false,
|
|
extnValue = public_key:der_encode('SubjectAltName', SANs)}.
|
|
|
|
extension_request(SANs) ->
|
|
#'AttributePKCS-10'{
|
|
type = ?'pkcs-9-at-extensionRequest',
|
|
values = [{'asn1_OPENTYPE',
|
|
public_key:der_encode(
|
|
'ExtensionRequest',
|
|
[extension(SANs)])}]
|
|
}.
|
|
|
|
certificate_request_info(SubPKInfo, Subject, ExtensionRequest) ->
|
|
#'CertificationRequestInfo'{
|
|
version = 0,
|
|
subject = Subject,
|
|
subjectPKInfo = SubPKInfo,
|
|
attributes = [ExtensionRequest]
|
|
}.
|
|
|
|
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. Actually it doesn't matter in
|
|
%% practice. Only in theory in order to have cleaner code.
|
|
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(subjectAltName) -> ?'id-ce-subjectAltName';
|
|
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.
|
|
|
|
-spec not_before_not_after() -> {binary(), binary()}.
|
|
not_before_not_after() ->
|
|
{Date, Time} = calendar:universal_time(),
|
|
NotBefore = encode_calendar_datetime({Date, Time}),
|
|
%% The certificate will be valid for 90 Days after today
|
|
AfterDate = add_days_to_date(90, Date),
|
|
NotAfter = encode_calendar_datetime({AfterDate, Time}),
|
|
{NotBefore, NotAfter}.
|
|
|
|
-spec to_public(jose_jwk:key()) -> jose_jwk:key().
|
|
to_public(PrivateKey) ->
|
|
jose_jwk:to_public(PrivateKey).
|
|
|
|
-spec pem_to_certificate(pem()) -> #'Certificate'{}.
|
|
pem_to_certificate(Pem) ->
|
|
PemList = public_key:pem_decode(Pem),
|
|
PemEntryCert = lists:keyfind('Certificate', 1, PemList),
|
|
Certificate = public_key:pem_entry_decode(PemEntryCert),
|
|
Certificate.
|
|
|
|
-spec add_days_to_date(integer(), calendar:date()) -> calendar:date().
|
|
add_days_to_date(Days, Date) ->
|
|
Date1 = calendar:date_to_gregorian_days(Date),
|
|
calendar:gregorian_days_to_date(Date1 + Days).
|
|
|
|
-spec encode_calendar_datetime(calendar:datetime()) -> binary().
|
|
encode_calendar_datetime({{Year, Month, Day}, {Hour, Minute, Second}}) ->
|
|
list_to_binary(io_lib:format("~4..0B-~2..0B-~2..0BT"
|
|
"~2..0B:~2..0B:~2..0BZ",
|
|
[Year, Month, Day, Hour, Minute, Second])).
|
|
|
|
%% TODO: Find a better and more robust way to parse the utc string
|
|
-spec utc_string_to_datetime(string()) -> calendar:datetime().
|
|
utc_string_to_datetime(UtcString) ->
|
|
try
|
|
[Y1,Y2,MO1,MO2,D1,D2,H1,H2,MI1,MI2,S1,S2,$Z] = UtcString,
|
|
Year = list_to_integer("20" ++ [Y1,Y2]),
|
|
Month = list_to_integer([MO1, MO2]),
|
|
Day = list_to_integer([D1,D2]),
|
|
Hour = list_to_integer([H1,H2]),
|
|
Minute = list_to_integer([MI1,MI2]),
|
|
Second = list_to_integer([S1,S2]),
|
|
{{Year, Month, Day}, {Hour, Minute, Second}}
|
|
catch
|
|
_:_ ->
|
|
?ERROR_MSG("Unable to parse UTC string", []),
|
|
throw({error, utc_string_to_datetime})
|
|
end.
|
|
|
|
-spec find_private_key_in_pem(pem()) -> {ok, jose_jwk:key()} | false.
|
|
find_private_key_in_pem(Pem) ->
|
|
PemList = public_key:pem_decode(Pem),
|
|
case find_private_key_in_pem1(private_key_types(), PemList) of
|
|
false ->
|
|
false;
|
|
PemKey ->
|
|
Key = public_key:pem_entry_decode(PemKey),
|
|
JoseKey = jose_jwk:from_key(Key),
|
|
{ok, JoseKey}
|
|
end.
|
|
|
|
|
|
-spec find_private_key_in_pem1([public_key:pki_asn1_type()],
|
|
[public_key:pem_entry()]) ->
|
|
public_key:pem_entry() | false.
|
|
find_private_key_in_pem1([], _PemList) ->
|
|
false;
|
|
find_private_key_in_pem1([Type|Types], PemList) ->
|
|
case lists:keyfind(Type, 1, PemList) of
|
|
false ->
|
|
find_private_key_in_pem1(Types, PemList);
|
|
Key ->
|
|
Key
|
|
end.
|
|
|
|
|
|
-spec parse_domain_string(string()) -> [string()].
|
|
parse_domain_string(DomainString) ->
|
|
string:tokens(DomainString, ";").
|
|
|
|
-spec private_key_types() -> [public_key:pki_asn1_type()].
|
|
private_key_types() ->
|
|
['RSAPrivateKey',
|
|
'DSAPrivateKey',
|
|
'ECPrivateKey'].
|
|
|
|
-spec find_all_sub_domains(bitstring()) -> [bitstring()].
|
|
find_all_sub_domains(DomainName) ->
|
|
AllRoutes = ejabberd_router:get_all_routes(),
|
|
DomainLen = size(DomainName),
|
|
[Route || Route <- AllRoutes,
|
|
binary:longest_common_suffix([DomainName, Route])
|
|
=:= DomainLen].
|
|
|
|
|
|
-spec is_error(_) -> boolean().
|
|
is_error({error, _}) -> true;
|
|
is_error({error, _, _}) -> true;
|
|
is_error(_) -> false.
|
|
|
|
%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%
|
|
%%
|
|
%% Handle the persistent data structure
|
|
%%
|
|
%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%
|
|
-spec data_empty() -> [].
|
|
data_empty() ->
|
|
[].
|
|
|
|
%%
|
|
%% Account
|
|
%%
|
|
|
|
-spec data_get_account(acme_data()) -> {ok, list(), url(), jose_jwk:key()} | none.
|
|
data_get_account(Data) ->
|
|
case lists:keyfind(account, 1, Data) of
|
|
{account, #data_acc{id = AccId, ca_url = CAUrl, key = PrivateKey}} ->
|
|
{ok, AccId, CAUrl, PrivateKey};
|
|
false ->
|
|
none
|
|
end.
|
|
|
|
-spec data_set_account(acme_data(), {list(), url(), jose_jwk:key()}) -> acme_data().
|
|
data_set_account(Data, {AccId, CAUrl, PrivateKey}) ->
|
|
NewAcc = {account, #data_acc{id = AccId, ca_url = CAUrl, key = PrivateKey}},
|
|
lists:keystore(account, 1, Data, NewAcc).
|
|
|
|
%%
|
|
%% Certificates
|
|
%%
|
|
|
|
-spec data_get_certificates(acme_data()) -> data_certs().
|
|
data_get_certificates(Data) ->
|
|
case lists:keyfind(certs, 1, Data) of
|
|
{certs, Certs} ->
|
|
Certs;
|
|
false ->
|
|
[]
|
|
end.
|
|
|
|
-spec data_set_certificates(acme_data(), data_certs()) -> acme_data().
|
|
data_set_certificates(Data, NewCerts) ->
|
|
lists:keystore(certs, 1, Data, {certs, NewCerts}).
|
|
|
|
%% ATM we preserve one certificate for each domain
|
|
-spec data_add_certificate(acme_data(), data_cert()) -> acme_data().
|
|
data_add_certificate(Data, DataCert = #data_cert{domain=Domain}) ->
|
|
Certs = data_get_certificates(Data),
|
|
NewCerts = lists:keystore(Domain, 1, Certs, {Domain, DataCert}),
|
|
data_set_certificates(Data, NewCerts).
|
|
|
|
-spec data_remove_certificate(acme_data(), data_cert()) -> acme_data().
|
|
data_remove_certificate(Data, _DataCert = #data_cert{domain=Domain}) ->
|
|
Certs = data_get_certificates(Data),
|
|
NewCerts = lists:keydelete(Domain, 1, Certs),
|
|
data_set_certificates(Data, NewCerts).
|
|
|
|
|
|
%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%
|
|
%%
|
|
%% Handle Config and Persistence Files
|
|
%%
|
|
%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%
|
|
|
|
-spec persistent_file() -> file:filename().
|
|
persistent_file() ->
|
|
AcmeDir = acme_certs_dir(),
|
|
filename:join(AcmeDir, "acme.DAT").
|
|
|
|
%% The persistent file should be read and written only by its owner
|
|
-spec file_mode() -> 384.
|
|
file_mode() ->
|
|
8#600.
|
|
|
|
-spec read_persistent() -> {ok, acme_data()} | no_return().
|
|
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.
|
|
|
|
-spec write_persistent(acme_data()) -> ok | no_return().
|
|
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.
|
|
|
|
-spec create_persistent() -> ok | no_return().
|
|
create_persistent() ->
|
|
Binary = term_to_binary(data_empty()),
|
|
case file:write_file(persistent_file(), Binary) of
|
|
ok ->
|
|
case file:change_mode(persistent_file(), 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.
|
|
|
|
-spec write_account_persistent({list(), url(), jose_jwk:key()}) -> ok | no_return().
|
|
write_account_persistent({AccId, CAUrl, PrivateKey}) ->
|
|
{ok, Data} = read_persistent(),
|
|
NewData = data_set_account(Data, {AccId, CAUrl, PrivateKey}),
|
|
ok = write_persistent(NewData).
|
|
|
|
-spec read_account_persistent() -> {ok, list(), url(), jose_jwk:key()} | none.
|
|
read_account_persistent() ->
|
|
{ok, Data} = read_persistent(),
|
|
data_get_account(Data).
|
|
|
|
-spec read_certificates_persistent() -> data_certs().
|
|
read_certificates_persistent() ->
|
|
{ok, Data} = read_persistent(),
|
|
data_get_certificates(Data).
|
|
|
|
-spec add_certificate_persistent(data_cert()) -> ok.
|
|
add_certificate_persistent(DataCert) ->
|
|
{ok, Data} = read_persistent(),
|
|
NewData = data_add_certificate(Data, DataCert),
|
|
ok = write_persistent(NewData).
|
|
|
|
-spec remove_certificate_persistent(data_cert()) -> ok.
|
|
remove_certificate_persistent(DataCert) ->
|
|
{ok, Data} = read_persistent(),
|
|
NewData = data_remove_certificate(Data, DataCert),
|
|
ok = write_persistent(NewData).
|
|
|
|
-spec save_certificate({ok, bitstring(), binary()} | {error, _, _}) ->
|
|
{ok, bitstring(), saved} | {error, bitstring(), _}.
|
|
save_certificate({error, _, _} = Error) ->
|
|
Error;
|
|
save_certificate({ok, DomainName, Cert}) ->
|
|
try
|
|
CertDir = acme_certs_dir(),
|
|
DomainString = bitstring_to_list(DomainName),
|
|
CertificateFile = filename:join([CertDir, DomainString ++ ".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),
|
|
ok = ejabberd_pkix:add_certfile(CertificateFile),
|
|
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.
|
|
|
|
-spec save_renewed_certificate({ok, bitstring(), _} | {error, _, _}) ->
|
|
{ok, bitstring(), _} | {error, bitstring(), _}.
|
|
save_renewed_certificate({error, _, _} = Error) ->
|
|
Error;
|
|
save_renewed_certificate({ok, _, no_expire} = Cert) ->
|
|
Cert;
|
|
save_renewed_certificate({ok, DomainName, Cert}) ->
|
|
save_certificate({ok, DomainName, Cert}).
|
|
|
|
-spec register_certfiles() -> ok.
|
|
register_certfiles() ->
|
|
Dir = acme_certs_dir(),
|
|
Paths = filelib:wildcard(filename:join(Dir, "*.pem")),
|
|
lists:foreach(
|
|
fun(Path) ->
|
|
ejabberd_pkix:add_certfile(Path)
|
|
end, Paths).
|
|
|
|
-spec write_cert(file:filename(), binary(), bitstring()) -> {ok, bitstring(), saved}.
|
|
write_cert(CertificateFile, Cert, DomainName) ->
|
|
case file:write_file(CertificateFile, Cert) of
|
|
ok ->
|
|
case file:change_mode(CertificateFile, file_mode()) of
|
|
ok -> ok;
|
|
{error, Why} ->
|
|
?WARNING_MSG("Failed to change mode of file ~s: ~s",
|
|
[CertificateFile, file:format_error(Why)])
|
|
end,
|
|
{ok, DomainName, saved};
|
|
{error, Reason} ->
|
|
?ERROR_MSG("Error: ~p saving certificate at file: ~p",
|
|
[Reason, CertificateFile]),
|
|
throw({error, DomainName, saving})
|
|
end.
|
|
|
|
-spec get_config_acme() -> acme_config().
|
|
get_config_acme() ->
|
|
case ejabberd_config:get_option(acme, undefined) of
|
|
undefined ->
|
|
?WARNING_MSG("No acme configuration has been specified", []),
|
|
%% throw({error, configuration});
|
|
[];
|
|
Acme ->
|
|
Acme
|
|
end.
|
|
|
|
-spec get_config_contact() -> bitstring().
|
|
get_config_contact() ->
|
|
Acme = get_config_acme(),
|
|
case lists:keyfind(contact, 1, Acme) of
|
|
{contact, Contact} ->
|
|
Contact;
|
|
false ->
|
|
?WARNING_MSG("No contact has been specified in configuration", []),
|
|
?DEFAULT_CONFIG_CONTACT
|
|
%% throw({error, configuration_contact})
|
|
end.
|
|
|
|
-spec get_config_ca_url() -> url().
|
|
get_config_ca_url() ->
|
|
Acme = get_config_acme(),
|
|
case lists:keyfind(ca_url, 1, Acme) of
|
|
{ca_url, CAUrl} ->
|
|
CAUrl;
|
|
false ->
|
|
?ERROR_MSG("No CA url has been specified in configuration", []),
|
|
?DEFAULT_CONFIG_CA_URL
|
|
%% throw({error, configuration_ca_url})
|
|
end.
|
|
|
|
|
|
-spec get_config_hosts() -> [bitstring()].
|
|
get_config_hosts() ->
|
|
case ejabberd_config:get_option(hosts, undefined) of
|
|
undefined ->
|
|
?ERROR_MSG("No hosts have been specified in configuration", []),
|
|
throw({error, configuration_hosts});
|
|
Hosts ->
|
|
Hosts
|
|
end.
|
|
|
|
-spec acme_certs_dir() -> file:filename().
|
|
acme_certs_dir() ->
|
|
filename:join(ejabberd_pkix:certs_dir(), "acme").
|
|
|
|
generate_key() ->
|
|
jose_jwk:generate_key({ec, secp256r1}).
|
|
|
|
%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%
|
|
%%
|
|
%% Option Parsing Code
|
|
%%
|
|
%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%
|
|
-spec opt_type(acme) -> fun((acme_config()) -> (acme_config()));
|
|
(atom()) -> [atom()].
|
|
opt_type(acme) ->
|
|
fun(L) ->
|
|
lists:map(
|
|
fun({ca_url, URL}) ->
|
|
URL1 = binary_to_list(URL),
|
|
{ok, _} = http_uri:parse(URL1),
|
|
{ca_url, URL1};
|
|
({contact, Contact}) ->
|
|
[<<_, _/binary>>, <<_, _/binary>>] =
|
|
binary:split(Contact, <<":">>),
|
|
{contact, Contact}
|
|
end, L)
|
|
end;
|
|
opt_type(_) ->
|
|
[acme].
|