aboutsummaryrefslogtreecommitdiff
path: root/src/ejabberd_acme.erl
diff options
context:
space:
mode:
Diffstat (limited to 'src/ejabberd_acme.erl')
-rw-r--r--src/ejabberd_acme.erl881
1 files changed, 881 insertions, 0 deletions
diff --git a/src/ejabberd_acme.erl b/src/ejabberd_acme.erl
new file mode 100644
index 000000000..cb7b6525e
--- /dev/null
+++ b/src/ejabberd_acme.erl
@@ -0,0 +1,881 @@
+-module (ejabberd_acme).
+
+-export([%% Ejabberdctl Commands
+ get_certificates/2,
+ list_certificates/1,
+ %% Command Options Validity
+ is_valid_account_opt/1,
+ is_valid_verbose_opt/1,
+ %% Misc
+ generate_key/0,
+ %% Debugging Scenarios
+ scenario/3,
+ scenario0/2,
+ new_user_scenario/2
+ %% 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").
+
+
+
+%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%
+%%
+%% 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.
+
+%%
+%% List Certificates
+%%
+
+list_certificates(Verbose) ->
+ try
+ list_certificates0(Verbose)
+ catch
+ throw:Throw ->
+ Throw;
+ E:R ->
+ ?ERROR_MSG("Unknown ~p:~p, ~p", [E, R, erlang:get_stacktrace()]),
+ {error, get_certificates}
+ end.
+
+list_certificates0(Verbose) ->
+ {ok, Certs} = read_certificates_persistent(),
+ case Verbose of
+ "plain" ->
+ [format_certificate(DataCert) || {_Key, DataCert} <- Certs];
+ "verbose" ->
+ Certs
+ end.
+
+%% TODO: Make this cleaner and more robust
+format_certificate(DataCert) ->
+ #data_cert{
+ domain = DomainName,
+ pem = PemCert,
+ path = Path
+ } = DataCert,
+
+ PemList = public_key:pem_decode(PemCert),
+ PemEntryCert = lists:keyfind('Certificate', 1, PemList),
+ Certificate = public_key:pem_entry_decode(PemEntryCert),
+
+ %% Find the commonName
+ _CommonName = get_commonName(Certificate),
+
+ %% Find the notAfter date
+ NotAfter = get_notAfter(Certificate),
+
+ format_certificate1(DomainName, NotAfter, Path).
+
+format_certificate1(DomainName, NotAfter, Path) ->
+ Result = lists:flatten(io_lib:format(
+ " Domain: ~s~n"
+ " Valid until: ~s UTC~n"
+ " Path: ~s",
+ [DomainName, NotAfter, Path])),
+ Result.
+
+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.
+
+get_notAfter(#'Certificate'{tbsCertificate = TbsCertificate}) ->
+ #'TBSCertificate'{
+ validity = Validity
+ } = TbsCertificate,
+
+ %% TODO: Find a library function to decode utc time
+ #'Validity'{notAfter = {utcTime, UtcTime}} = Validity,
+ [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]])),
+
+ NotAfter.
+
+%%
+%% Get Certificate
+%%
+
+%% Needs a hell lot of cleaning
+-spec get_certificates(url(), account_opt()) ->
+ [{'ok', bitstring(), 'saved'} | {'error', bitstring(), _}] |
+ {'error', _}.
+get_certificates(CAUrl, NewAccountOpt) ->
+ try
+ ?INFO_MSG("Persistent: ~p~n", [file:read_file_info(persistent_file())]),
+ get_certificates0(CAUrl, NewAccountOpt)
+ catch
+ throw:Throw ->
+ Throw;
+ E:R ->
+ ?ERROR_MSG("Unknown ~p:~p, ~p", [E, R, erlang:get_stacktrace()]),
+ {error, get_certificates}
+ end.
+
+-spec get_certificates0(url(), account_opt()) ->
+ [{'ok', bitstring(), 'saved'} | {'error', bitstring(), _}] |
+ no_return().
+get_certificates0(CAUrl, "old-account") ->
+ %% Get the current account
+ {ok, _AccId, PrivateKey} = ensure_account_exists(),
+
+ get_certificates1(CAUrl, PrivateKey);
+
+get_certificates0(CAUrl, "new-account") ->
+ %% Create a new account and save it to disk
+ {ok, _Id, PrivateKey} = create_save_new_account(CAUrl),
+
+ get_certificates1(CAUrl, PrivateKey).
+
+-spec get_certificates1(url(), jose_jwk:key()) ->
+ [{'ok', bitstring(), 'saved'} | {'error', bitstring(), _}] |
+ no_return().
+get_certificates1(CAUrl, PrivateKey) ->
+ %% Read Config
+ {ok, Hosts} = get_config_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
+ %% Result
+ SavedCerts.
+
+-spec get_certificate(url(), bitstring(), jose_jwk:key()) ->
+ {'ok', bitstring(), pem_certificate()} |
+ {'error', bitstring(), _}.
+get_certificate(CAUrl, DomainName, PrivateKey) ->
+ ?INFO_MSG("Getting a Certificate for domain: ~p~n", [DomainName]),
+ try
+ {ok, _Authz} = create_new_authorization(CAUrl, DomainName, PrivateKey),
+ create_new_certificate(CAUrl, DomainName, 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
+ {ok, 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, 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) ->
+ 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})
+ end.
+
+create_new_certificate(CAUrl, DomainName, PrivateKey) ->
+ try
+ {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(),
+ Req =
+ [{<<"csr">>, CSR},
+ {<<"notBefore">>, NotBefore},
+ {<<"NotAfter">>, NotAfter}
+ ],
+ {ok, {_CertUrl, 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),
+
+ {_, CSRKeyKey} = jose_jwk:to_key(CSRKey),
+ PemEntryKey = public_key:pem_entry_encode('ECPrivateKey', CSRKeyKey),
+
+ PemCertKey = public_key:pem_encode([PemEntryKey, PemEntryCert]),
+
+ {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.
+
+ensure_account_exists() ->
+ case read_account_persistent() of
+ none ->
+ ?ERROR_MSG("No existing account", []),
+ throw({error, no_old_account});
+ {ok, AccId, PrivateKey} ->
+ {ok, AccId, PrivateKey}
+ end.
+
+%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%
+%%
+%% Certificate Request Functions
+%%
+%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%
+
+%% For now we accept only generating a key of
+%% specific type for signing the csr
+%% TODO: Make this function handle more signing keys
+%% 1. Derive oid from Key
+%% 2. Derive the whole algo objects from Key
+%% TODO: Encode Strings using length using a library function
+
+-spec make_csr(proplist()) -> {binary(), jose_jwk:key()}.
+make_csr(Attributes) ->
+ Key = generate_key(),
+ {_, KeyKey} = jose_jwk:to_key(Key),
+ KeyPub = jose_jwk: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),
+ CRI = certificate_request_info(SubPKInfo, Subject),
+ {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
+ }.
+
+certificate_request_info(SubPKInfo, Subject) ->
+ #'CertificationRequestInfo'{
+ version = 0,
+ subject = Subject,
+ subjectPKInfo = SubPKInfo,
+ attributes = []
+ }.
+
+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
+ 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(_) -> 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.
+
+not_before_not_after() ->
+ %% TODO: Make notBefore and notAfter like they do it in other clients
+ {MegS, Sec, MicS} = erlang:timestamp(),
+ NotBefore = xmpp_util:encode_timestamp({MegS-1, Sec, MicS}),
+ NotAfter = xmpp_util:encode_timestamp({MegS+1, Sec, MicS}),
+ {NotBefore, NotAfter}.
+
+is_error({error, _}) -> true;
+is_error(_) -> false.
+
+%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%
+%%
+%% Handle the persistent data structure
+%%
+%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%
+
+data_empty() ->
+ [].
+
+%%
+%% Account
+%%
+
+data_get_account(Data) ->
+ case lists:keyfind(account, 1, Data) of
+ {account, #data_acc{id = AccId, key = PrivateKey}} ->
+ {ok, AccId, PrivateKey};
+ false ->
+ none
+ end.
+
+data_set_account(Data, {AccId, PrivateKey}) ->
+ NewAcc = {account, #data_acc{id = AccId, key = PrivateKey}},
+ lists:keystore(account, 1, Data, NewAcc).
+
+%%
+%% Certificates
+%%
+
+data_get_certificates(Data) ->
+ case lists:keyfind(certs, 1, Data) of
+ {certs, Certs} ->
+ {ok, Certs};
+ false ->
+ {ok, []}
+ end.
+
+data_set_certificates(Data, NewCerts) ->
+ lists:keystore(certs, 1, Data, {certs, NewCerts}).
+
+%% ATM we preserve one certificate for each domain
+data_add_certificate(Data, DataCert = #data_cert{domain=Domain}) ->
+ {ok, Certs} = data_get_certificates(Data),
+ NewCerts = lists:keystore(Domain, 1, Certs, {Domain, DataCert}),
+ data_set_certificates(Data, NewCerts).
+
+
+%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%
+%%
+%% Handle Config and Persistence Files
+%%
+%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%
+
+persistent_file() ->
+ MnesiaDir = mnesia:system_info(directory),
+ filename:join(MnesiaDir, "acme.DAT").
+
+%% The persistent file should be rread and written only by its owner
+persistent_file_mode() ->
+ 8#400 + 8#200.
+
+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.
+
+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.
+
+create_persistent() ->
+ Binary = term_to_binary(data_empty()),
+ case file:write_file(persistent_file(), Binary) of
+ ok ->
+ case file:change_mode(persistent_file(), persistent_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.
+
+write_account_persistent({AccId, PrivateKey}) ->
+ {ok, Data} = read_persistent(),
+ NewData = data_set_account(Data, {AccId, PrivateKey}),
+ ok = write_persistent(NewData).
+
+read_account_persistent() ->
+ {ok, Data} = read_persistent(),
+ data_get_account(Data).
+
+read_certificates_persistent() ->
+ {ok, Data} = read_persistent(),
+ data_get_certificates(Data).
+
+add_certificate_persistent(DataCert) ->
+ {ok, Data} = read_persistent(),
+ NewData = data_add_certificate(Data, DataCert),
+ ok = write_persistent(NewData).
+
+
+save_certificate({error, _, _} = Error) ->
+ Error;
+save_certificate({ok, DomainName, Cert}) ->
+ try
+ {ok, CertDir} = get_config_cert_dir(),
+ DomainString = bitstring_to_list(DomainName),
+ CertificateFile = filename:join([CertDir, DomainString ++ "_cert.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),
+ 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.
+
+write_cert(CertificateFile, Cert, DomainName) ->
+ case file:write_file(CertificateFile, Cert) of
+ ok ->
+ {ok, DomainName, saved};
+ {error, Reason} ->
+ ?ERROR_MSG("Error: ~p saving certificate at file: ~p",
+ [Reason, CertificateFile]),
+ throw({error, DomainName, saving})
+ end.
+
+get_config_acme() ->
+ case ejabberd_config:get_option(acme, undefined) of
+ undefined ->
+ ?ERROR_MSG("No acme configuration has been specified", []),
+ throw({error, configuration});
+ Acme ->
+ {ok, Acme}
+ end.
+
+get_config_contact() ->
+ {ok, Acme} = get_config_acme(),
+ case lists:keyfind(contact, 1, Acme) of
+ {contact, Contact} ->
+ {ok, Contact};
+ false ->
+ ?ERROR_MSG("No contact has been specified", []),
+ throw({error, configuration_contact})
+ end.
+
+get_config_hosts() ->
+ case ejabberd_config:get_option(hosts, undefined) of
+ undefined ->
+ ?ERROR_MSG("No hosts have been specified", []),
+ throw({error, configuration_hosts});
+ Hosts ->
+ {ok, Hosts}
+ end.
+
+get_config_cert_dir() ->
+ case ejabberd_config:get_option(cert_dir, undefined) of
+ undefined ->
+ ?ERROR_MSG("No cert_dir configuration has been specified", []),
+ throw({error, configuration});
+ CertDir ->
+ {ok, CertDir}
+ end.
+
+%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%
+%%
+%% Transaction Fun
+%%
+%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%
+
+transaction([{Fun, Rollback} | Rest]) ->
+ try
+ {ok, Result} = Fun(),
+ [Result | transaction(Rest)]
+ catch Type:Reason ->
+ Rollback(),
+ erlang:raise(Type, Reason, erlang:get_stacktrace())
+ end;
+transaction([Fun | Rest]) ->
+ % not every action require cleanup on error
+ transaction([{Fun, fun () -> ok end} | Rest]);
+transaction([]) -> [].
+
+%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%
+%%
+%% Debugging Funcs -- They are only used for the development phase
+%%
+%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%
+
+%% A typical acme workflow
+scenario(CAUrl, AccId, PrivateKey) ->
+ {ok, Dirs, Nonce0} = ejabberd_acme_comm:directory(CAUrl),
+
+ {ok, {_TOS, Account}, Nonce1} =
+ ejabberd_acme_comm:get_account({CAUrl, AccId}, PrivateKey, Nonce0),
+ ?INFO_MSG("Account: ~p~n", [Account]),
+
+ Req =
+ [{<<"identifier">>,
+ {[{<<"type">>, <<"dns">>},
+ {<<"value">>, <<"my-acme-test-ejabberd.com">>}]}},
+ {<<"existing">>, <<"accept">>}
+ ],
+ {ok, Authz, Nonce2} = ejabberd_acme_comm:new_authz(Dirs, PrivateKey, Req, Nonce1),
+
+ {Account, Authz, PrivateKey}.
+
+
+new_user_scenario(CAUrl, HttpDir) ->
+ PrivateKey = generate_key(),
+
+ {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} = ejabberd_acme_comm:new_account(Dirs, PrivateKey, Req0, Nonce0),
+
+ {_, AccIdInt} = proplists:lookup(<<"id">>, Account),
+ AccId = integer_to_list(AccIdInt),
+ {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} =
+ ejabberd_acme_comm:update_account({CAUrl, AccId}, PrivateKey, Req1, Nonce2),
+
+ %% NewKey = generate_key(),
+ %% KeyChangeUrl = CAUrl ++ "/acme/key-change/",
+ %% {ok, Account3, Nonce4} = key_roll_over(KeyChangeUrl, AccURL, PrivateKey, NewKey, Nonce3),
+ %% ?INFO_MSG("Changed key: ~p~n", [Account3]),
+
+ %% {ok, {_TOS, Account4}, Nonce5} = get_account(AccURL, NewKey, Nonce4),
+ %% ?INFO_MSG("New account:~p~n", [Account4]),
+ %% {Account4, PrivateKey}.
+
+ AccIdBin = list_to_bitstring(integer_to_list(AccIdInt)),
+ DomainName = << <<"my-acme-test-ejabberd">>/binary, AccIdBin/binary, <<".com">>/binary >>,
+ Req2 =
+ [{<<"identifier">>,
+ {[{<<"type">>, <<"dns">>},
+ {<<"value">>, DomainName}]}},
+ {<<"existing">>, <<"accept">>}
+ ],
+ {ok, {AuthzUrl, Authz}, Nonce4} =
+ ejabberd_acme_comm:new_authz(Dirs, PrivateKey, Req2, Nonce3),
+
+ {ok, AuthzId} = location_to_id(AuthzUrl),
+ {ok, Authz2, Nonce5} = ejabberd_acme_comm:get_authz({CAUrl, AuthzId}),
+ ?INFO_MSG("AuthzUrl: ~p~n", [AuthzUrl]),
+
+ Challenges = get_challenges(Authz2),
+ ?INFO_MSG("Challenges: ~p~n", [Challenges]),
+
+ {ok, ChallengeUrl, KeyAuthz} =
+ acme_challenge:solve_challenge(<<"http-01">>, Challenges, {PrivateKey, HttpDir}),
+ ?INFO_MSG("File for http-01 challenge written correctly", []),
+
+ {ok, ChallengeId} = location_to_id(ChallengeUrl),
+ Req3 =
+ [ {<<"type">>, <<"http-01">>}
+ , {<<"keyAuthorization">>, KeyAuthz}
+ ],
+ {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} = ejabberd_acme_comm:get_authz_until_valid({CAUrl, AuthzId}),
+
+ #{"new-cert" := NewCert} = Dirs,
+ CSRSubject = [{commonName, bitstring_to_list(DomainName)}],
+ {CSR, CSRKey} = make_csr(CSRSubject),
+ {MegS, Sec, MicS} = erlang:timestamp(),
+ NotBefore = xmpp_util:encode_timestamp({MegS-1, Sec, MicS}),
+ NotAfter = xmpp_util:encode_timestamp({MegS+1, Sec, MicS}),
+ Req4 =
+ [{<<"csr">>, CSR},
+ {<<"notBefore">>, NotBefore},
+ {<<"NotAfter">>, NotAfter}
+ ],
+ {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} = ejabberd_acme_comm:get_cert({CAUrl, CertId}),
+
+ DecodedCert = public_key:pkix_decode_cert(list_to_binary(Certificate2), plain),
+ %% ?INFO_MSG("DecodedCert: ~p~n", [DecodedCert]),
+ PemEntryCert = public_key:pem_entry_encode('Certificate', DecodedCert),
+ %% ?INFO_MSG("PemEntryCert: ~p~n", [PemEntryCert]),
+
+ {_, CSRKeyKey} = jose_jwk:to_key(CSRKey),
+ PemEntryKey = public_key:pem_entry_encode('ECPrivateKey', CSRKeyKey),
+ %% ?INFO_MSG("PemKey: ~p~n", [jose_jwk:to_pem(CSRKey)]),
+ %% ?INFO_MSG("PemEntryKey: ~p~n", [PemEntryKey]),
+
+ PemCert = public_key:pem_encode([PemEntryKey, PemEntryCert]),
+ %% ?INFO_MSG("PemCert: ~p~n", [PemCert]),
+
+ ok = file:write_file(HttpDir ++ "/my_server.pem", PemCert),
+
+ Base64Cert = base64url:encode(Certificate2),
+ Req5 = [{<<"certificate">>, Base64Cert}],
+ {ok, [], Nonce10} = ejabberd_acme_comm:revoke_cert(Dirs, PrivateKey, Req5, Nonce9),
+
+ {ok, Certificate3, Nonce11} = ejabberd_acme_comm:get_cert({CAUrl, CertId}),
+
+ {Account2, Authz3, CSR, Certificate, PrivateKey}.
+
+-ifdef(GENERATE_RSA_KEY).
+generate_key() ->
+ ?INFO_MSG("Generate RSA key pair~n", []),
+ Key = public_key:generate_key({rsa, 2048, 65537}),
+ jose_jwk:from_key(Key).
+-else.
+generate_key() ->
+ ?INFO_MSG("Generate EC key pair~n", []),
+ jose_jwk:generate_key({ec, secp256r1}).
+-endif.
+
+
+scenario3() ->
+ CSRSubject = [{commonName, "my-acme-test-ejabberd.com"},
+ {organizationName, "Example Corp"}],
+ {CSR, CSRKey} = make_csr(CSRSubject).
+
+
+%% It doesn't seem to work, The user can get a new authorization even though the account has been deleted
+delete_account_scenario(CAUrl) ->
+ PrivateKey = generate_key(),
+
+ DirURL = CAUrl ++ "/directory",
+ {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} = ejabberd_acme_comm:new_account(Dirs, PrivateKey, Req0, Nonce0),
+
+ {_, AccIdInt} = proplists:lookup(<<"id">>, Account),
+ AccId = integer_to_list(AccIdInt),
+ {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} =
+ ejabberd_acme_comm:update_account({CAUrl, AccId}, PrivateKey, Req1, Nonce2),
+
+ %% Delete account
+ {ok, Account3, Nonce4} =
+ ejabberd_acme_comm:delete_account({CAUrl, AccId}, PrivateKey, Nonce3),
+
+ timer:sleep(3000),
+
+ {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)),
+ DomainName = << <<"my-acme-test-ejabberd">>/binary, AccIdBin/binary, <<".com">>/binary >>,
+ Req2 =
+ [{<<"identifier">>,
+ {[{<<"type">>, <<"dns">>},
+ {<<"value">>, DomainName}]}},
+ {<<"existing">>, <<"accept">>}
+ ],
+ {ok, {AuthzUrl, Authz}, Nonce6} =
+ ejabberd_acme_comm:new_authz(Dirs, PrivateKey, Req2, Nonce5),
+
+ {ok, Account1, Account3, Authz}.
+
+%% Just a test
+scenario0(KeyFile, HttpDir) ->
+ PrivateKey = jose_jwk:from_file(KeyFile),
+ %% scenario("http://localhost:4000", "2", PrivateKey).
+ %% delete_account_scenario("http://localhost:4000").
+ new_user_scenario("http://localhost:4000", HttpDir).
+
+%% scenario3().
+