-module (ejabberd_acme). -behaviour(gen_server). %% ejabberdctl commands -export([get_commands_spec/0, 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, register_certfiles/0]). -include("logger.hrl"). -include("xmpp.hrl"). -include("ejabberd_commands.hrl"). -include("ejabberd_acme.hrl"). -include_lib("public_key/include/public_key.hrl"). -include("ejabberd_stacktrace.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 currently 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; ?EX_RULE(E, R, St) -> StackTrace = ?EX_STACK(St), ?ERROR_MSG("Unknown ~p:~p, ~p", [E, R, 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(), [binary()]) -> 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', binary(), _} | {'error', binary(), _}]) -> 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', binary(), _} | {'error', binary(), _}) -> string(). format_get_certificate({ok, Domain, saved}) -> io_lib:format(" Certificate for domain: \"~s\" acquired and saved", [Domain]); format_get_certificate({error, 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(), binary(), jose_jwk:key()) -> {'ok', binary(), pem()} | {'error', binary(), _}. 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; ?EX_RULE(E, R, St) -> StackTrace = ?EX_STACK(St), ?ERROR_MSG("Unknown ~p:~p, ~p", [E, R, 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(), binary(), 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(), binary(), 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(), {binary(), [binary()]}, jose_jwk:key()) -> {ok, binary(), pem()}. create_new_certificate(CAUrl, {DomainName, AllSubDomains}, PrivateKey) -> try {ok, Dirs, Nonce0} = ejabberd_acme_comm:directory(CAUrl), CSRSubject = [{?'id-at-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; ?EX_RULE(E, R, St) -> StackTrace = ?EX_STACK(St), ?ERROR_MSG("Unknown ~p:~p, ~p", [E, R, 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(), {binary(), data_cert()}, jose_jwk:key()) -> {'ok', binary(), _} | {'error', binary(), _}. 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({binary(), 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; ?EX_RULE(E, R, St) -> StackTrace = ?EX_STACK(St), ?ERROR_MSG("Unknown ~p:~p, ~p", [E, R, 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 ?EX_RULE(E, R, St) -> StackTrace = ?EX_STACK(St), ?ERROR_MSG("Unknown ~p:~p, ~p", [E, R, StackTrace]), fail_format_certificate(DomainName) end. -spec format_certificate_plain(binary(), [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(binary(), [string()], {expired | ok, string()}, binary()) -> 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(binary()) -> 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(?'id-at-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 == ?'id-ce-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; ?EX_RULE(E, R, St) -> StackTrace = ?EX_STACK(St), ?ERROR_MSG("Unknown ~p:~p, ~p", [E, R, 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, binary()} | {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, binary()} | {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()) -> {binary(), 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(binary()) -> {binary(), 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, binary()}]) -> {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), 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 = ?'id-ce-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_from_list(Attrs) -> {rdnSequence, [[#'AttributeTypeAndValue'{ type = AttrName, value = public_key:der_encode('X520CommonName', {printableString, AttrVal}) }] || {AttrName, AttrVal} <- Attrs]}. %%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%% %% %% 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(binary()) -> [binary()]. 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, binary(), binary()} | {error, _, _}) -> {ok, binary(), saved} | {error, binary(), _}. 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; ?EX_RULE(E, R, St) -> StackTrace = ?EX_STACK(St), ?ERROR_MSG("Unknown ~p:~p, ~p", [E, R, StackTrace]), {error, DomainName, saving} end. -spec save_renewed_certificate({ok, binary(), _} | {error, _, _}) -> {ok, binary(), _} | {error, binary(), _}. 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(), binary()) -> ok. 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; {error, Reason} -> ?ERROR_MSG("Error: ~p saving certificate at file: ~p", [Reason, CertificateFile]), throw({error, DomainName, saving}) end. -spec get_config_contact() -> binary(). get_config_contact() -> Acme = ejabberd_option:acme(), try maps:get(contact, Acme) catch _:{badkey, _} -> ?WARNING_MSG("No contact has been specified in configuration", []), ?DEFAULT_CONFIG_CONTACT end. -spec get_config_ca_url() -> url(). get_config_ca_url() -> Acme = ejabberd_option:acme(), try maps:get(ca_url, Acme) catch _:{badkey, _} -> ?ERROR_MSG("No CA url has been specified in configuration", []), ?DEFAULT_CONFIG_CA_URL end. -spec get_config_hosts() -> [binary()]. get_config_hosts() -> ejabberd_option:hosts(). -spec acme_certs_dir() -> file:filename(). acme_certs_dir() -> filename:join(ejabberd_pkix:certs_dir(), "acme"). generate_key() -> jose_jwk:generate_key({ec, secp256r1}).