diff --git a/src/ejabberd_c2s.erl b/src/ejabberd_c2s.erl index ac48444cb..0ac39518f 100644 --- a/src/ejabberd_c2s.erl +++ b/src/ejabberd_c2s.erl @@ -949,7 +949,11 @@ opt_type(_) -> (atom()) -> [atom()]. listen_opt_type(access) -> fun acl:access_rules_validator/1; listen_opt_type(shaper) -> fun acl:shaper_rules_validator/1; -listen_opt_type(certfile) -> opt_type(c2s_certfile); +listen_opt_type(certfile) -> + fun(S) -> + ejabberd_pkix:add_certfile(S), + iolist_to_binary(S) + end; listen_opt_type(ciphers) -> opt_type(c2s_ciphers); listen_opt_type(dhfile) -> opt_type(c2s_dhfile); listen_opt_type(cafile) -> opt_type(c2s_cafile); diff --git a/src/ejabberd_http.erl b/src/ejabberd_http.erl index e19fbac44..9f2c87e9b 100644 --- a/src/ejabberd_http.erl +++ b/src/ejabberd_http.erl @@ -926,7 +926,10 @@ opt_type(_) -> [trusted_proxies]. listen_opt_type(tls) -> fun(B) when is_boolean(B) -> B end; listen_opt_type(certfile) -> - fun misc:try_read_file/1; + fun(S) -> + ejabberd_pkix:add_certfile(S), + iolist_to_binary(S) + end; listen_opt_type(ciphers) -> fun misc:try_read_file/1; listen_opt_type(dhfile) -> diff --git a/src/ejabberd_pkix.erl b/src/ejabberd_pkix.erl new file mode 100644 index 000000000..ffdc0cea4 --- /dev/null +++ b/src/ejabberd_pkix.erl @@ -0,0 +1,513 @@ +%%%------------------------------------------------------------------- +%%% @author Evgeny Khramtsov +%%% Created : 4 Mar 2017 by Evgeny Khramtsov +%%% +%%% +%%% ejabberd, Copyright (C) 2002-2017 ProcessOne +%%% +%%% This program is free software; you can redistribute it and/or +%%% modify it under the terms of the GNU General Public License as +%%% published by the Free Software Foundation; either version 2 of the +%%% License, or (at your option) any later version. +%%% +%%% This program is distributed in the hope that it will be useful, +%%% but WITHOUT ANY WARRANTY; without even the implied warranty of +%%% MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +%%% General Public License for more details. +%%% +%%% You should have received a copy of the GNU General Public License along +%%% with this program; if not, write to the Free Software Foundation, Inc., +%%% 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. +%%% +%%%------------------------------------------------------------------- +-module(ejabberd_pkix). + +-behaviour(gen_server). +-behaviour(ejabberd_config). + +%% API +-export([start_link/0, add_certfile/1, format_error/1, opt_type/1, + get_certfile/1, route_registered/1]). +%% gen_server callbacks +-export([init/1, handle_call/3, handle_cast/2, handle_info/2, + terminate/2, code_change/3]). + +-include_lib("public_key/include/public_key.hrl"). +-include("logger.hrl"). +-include("jid.hrl"). + +-record(state, {validate = true :: boolean(), + certs = #{}}). +-record(cert_state, {domains = [] :: [binary()]}). + +-type cert() :: #'OTPCertificate'{}. +-type priv_key() :: public_key:private_key(). +-type pub_key() :: #'RSAPublicKey'{} | {integer(), #'Dss-Parms'{}} | #'ECPoint'{}. +-type bad_cert_reason() :: cert_expired | invalid_issuer | invalid_signature | + name_not_permitted | missing_basic_constraint | + invalid_key_usage | selfsigned_peer | unknown_sig_algo | + unknown_ca | missing_priv_key. +-type bad_cert() :: {bad_cert, bad_cert_reason()}. +-type cert_error() :: not_cert | not_der | not_pem | encrypted. +-export_type([cert_error/0]). + +%%%=================================================================== +%%% API +%%%=================================================================== +-spec add_certfile(filename:filename()) + -> ok | {error, cert_error() | file:posix()}. +add_certfile(Path0) -> + Path = case filename:pathtype(Path0) of + relative -> + {ok, CWD} = file:get_cwd(), + iolist_to_binary(filename:join(CWD, Path0)); + _ -> + iolist_to_binary(Path0) + end, + gen_server:call(?MODULE, {add_certfile, Path}). + +route_registered(Route) -> + gen_server:call(?MODULE, {route_registered, Route}). + +-spec format_error(cert_error() | file:posix()) -> string(). +format_error(not_cert) -> + "no PEM encoded certificates found"; +format_error(not_pem) -> + "failed to decode from PEM format"; +format_error(not_der) -> + "failed to decode from DER format"; +format_error(encrypted) -> + "encrypted certificate found in the chain"; +format_error({bad_cert, cert_expired}) -> + "certificate is no longer valid as its expiration date has passed"; +format_error({bad_cert, invalid_issuer}) -> + "certificate issuer name does not match the name of the " + "issuer certificate in the chain"; +format_error({bad_cert, invalid_signature}) -> + "certificate was not signed by its issuer certificate in the chain"; +format_error({bad_cert, name_not_permitted}) -> + "invalid Subject Alternative Name extension"; +format_error({bad_cert, missing_basic_constraint}) -> + "certificate, required to have the basic constraints extension, " + "does not have a basic constraints extension"; +format_error({bad_cert, invalid_key_usage}) -> + "certificate key is used in an invalid way according " + "to the key-usage extension"; +format_error({bad_cert, selfsigned_peer}) -> + "self-signed certificate in the chain"; +format_error({bad_cert, unknown_sig_algo}) -> + "certificate is signed using unknown algorithm"; +format_error({bad_cert, unknown_ca}) -> + "certificate is signed by unknown CA"; +format_error({bad_cert, missing_priv_key}) -> + "no matching private key found for certificate in the chain"; +format_error({bad_cert, Unknown}) -> + lists:flatten(io_lib:format("~w", [Unknown])); +format_error(Why) -> + case file:format_error(Why) of + "unknown POSIX error" -> + atom_to_list(Why); + Reason -> + Reason + end. + +-spec get_certfile(binary()) -> {ok, binary()} | error. +get_certfile(Domain) -> + case ejabberd_idna:domain_utf8_to_ascii(Domain) of + false -> + error; + ASCIIDomain -> + case ets:lookup(?MODULE, ASCIIDomain) of + [] -> + case binary:split(ASCIIDomain, <<".">>, [trim]) of + [_, Host] -> + case ets:lookup(?MODULE, <<"*.", Host/binary>>) of + [{_, Path}|_] -> + {ok, Path}; + [] -> + error + end; + _ -> + error + end; + [{_, Path}|_] -> + {ok, Path} + end + end. + +start_link() -> + gen_server:start_link({local, ?MODULE}, ?MODULE, [], []). + +opt_type(ca_path) -> + fun(Path) -> iolist_to_binary(Path) end; +opt_type(_) -> + [ca_path]. + +%%%=================================================================== +%%% gen_server callbacks +%%%=================================================================== +init([]) -> + process_flag(trap_exit, true), + ets:new(?MODULE, [named_table, public, bag]), + ejabberd_hooks:add(route_registered, ?MODULE, route_registered, 50), + Validate = case os:type() of + {win32, _} -> false; + _ -> true + end, + if Validate -> check_ca_dir(); + true -> ok + end, + State = #state{validate = Validate}, + {ok, add_certfiles(State)}. + +handle_call({add_certfile, Path}, _, State) -> + {Result, NewState} = add_certfile(Path, State), + {reply, Result, NewState}; +handle_call({route_registered, Host}, _, State) -> + NewState = add_certfiles(Host, State), + case get_certfile(Host) of + {ok, _} -> ok; + error -> + ?WARNING_MSG("No certificate found matching '~s': strictly " + "configured clients or servers will reject " + "connections with this host", [Host]) + end, + {reply, ok, NewState}; +handle_call(_Request, _From, State) -> + Reply = ok, + {reply, Reply, State}. + +handle_cast(_Msg, State) -> + {noreply, State}. + +handle_info(_Info, State) -> + ?WARNING_MSG("unexpected info: ~p", [_Info]), + {noreply, State}. + +terminate(_Reason, _State) -> + ejabberd_hooks:delete(route_registered, ?MODULE, route_registered, 50). + +code_change(_OldVsn, State, _Extra) -> + {ok, State}. + +%%%=================================================================== +%%% Internal functions +%%%=================================================================== +add_certfiles(State) -> + lists:foldl( + fun(Host, AccState) -> + add_certfiles(Host, AccState) + end, State, ejabberd_config:get_myhosts()). + +add_certfiles(Host, State) -> + lists:foldl( + fun(Opt, AccState) -> + case ejabberd_config:get_option({Opt, Host}) of + undefined -> AccState; + Path -> + {_, NewAccState} = add_certfile(Path, AccState), + NewAccState + end + end, State, [c2s_certfile, s2s_certfile, domain_certfile]). + +add_certfile(Path, State) -> + case maps:get(Path, State#state.certs, undefined) of + #cert_state{} -> + {ok, State}; + undefined -> + case mk_cert_state(Path, State#state.validate) of + {error, Reason} -> + {{error, Reason}, State}; + {ok, CertState} -> + NewCerts = maps:put(Path, CertState, State#state.certs), + lists:foreach( + fun(Domain) -> + ets:insert(?MODULE, {Domain, Path}) + end, CertState#cert_state.domains), + {ok, State#state{certs = NewCerts}} + end + end. + +mk_cert_state(Path, Validate) -> + case check_certfile(Path, Validate) of + {ok, Ds} -> + {ok, #cert_state{domains = Ds}}; + {invalid, Ds, {bad_cert, _} = Why} -> + ?WARNING_MSG("certificate from ~s is invalid: ~s", + [Path, format_error(Why)]), + {ok, #cert_state{domains = Ds}}; + {error, Why} = Err -> + ?ERROR_MSG("failed to read certificate from ~s: ~s", + [Path, format_error(Why)]), + Err + end. + +-spec check_certfile(filename:filename(), boolean()) + -> {ok, [binary()]} | {invalid, [binary()], bad_cert()} | + {error, cert_error() | file:posix()}. +check_certfile(Path, Validate) -> + try + {ok, Data} = file:read_file(Path), + {ok, Certs, PrivKeys} = pem_decode(Data), + CertPaths = get_cert_paths(Certs), + Domains = get_domains(CertPaths), + case match_cert_keys(CertPaths, PrivKeys) of + {ok, _} -> + case validate(CertPaths, Validate) of + ok -> {ok, Domains}; + {error, Why} -> {invalid, Domains, Why} + end; + {error, Why} -> + {invalid, Domains, Why} + end + catch _:{badmatch, {error, _} = Err} -> + Err + end. + +-spec pem_decode(binary()) -> {ok, [cert()], [priv_key()]} | + {error, cert_error()}. +pem_decode(Data) -> + try public_key:pem_decode(Data) of + PemEntries -> + case decode_certs(PemEntries) of + {error, _} = Err -> + Err; + Objects -> + case lists:partition( + fun(#'OTPCertificate'{}) -> true; + (_) -> false + end, Objects) of + {[], _} -> + {error, not_cert}; + {Certs, PrivKeys} -> + {ok, Certs, PrivKeys} + end + end + catch _:_ -> + {error, not_pem} + end. + +-spec decode_certs([public_key:pem_entry()]) -> {[cert()], [priv_key()]} | + {error, not_der | encrypted}. +decode_certs(PemEntries) -> + try lists:foldr( + fun(_, {error, _} = Err) -> + Err; + ({_, _, Flag}, _) when Flag /= not_encrypted -> + {error, encrypted}; + ({'Certificate', Der, _}, Acc) -> + [public_key:pkix_decode_cert(Der, otp)|Acc]; + ({'PrivateKeyInfo', Der, not_encrypted}, Acc) -> + #'PrivateKeyInfo'{privateKeyAlgorithm = + #'PrivateKeyInfo_privateKeyAlgorithm'{ + algorithm = Algo}, + privateKey = Key} = + public_key:der_decode('PrivateKeyInfo', Der), + case Algo of + ?'rsaEncryption' -> + [public_key:der_decode( + 'RSAPrivateKey', iolist_to_binary(Key))|Acc]; + ?'id-dsa' -> + [public_key:der_decode( + 'DSAPrivateKey', iolist_to_binary(Key))|Acc]; + ?'id-ecPublicKey' -> + [public_key:der_decode( + 'ECPrivateKey', iolist_to_binary(Key))|Acc]; + _ -> + Acc + end; + ({Tag, Der, _}, Acc) when Tag == 'RSAPrivateKey'; + Tag == 'DSAPrivateKey'; + Tag == 'ECPrivateKey' -> + [public_key:der_decode(Tag, Der)|Acc]; + (_, Acc) -> + Acc + end, [], PemEntries) + catch _:_ -> + {error, not_der} + end. + +-spec validate([{path, [cert()]}], boolean()) -> ok | {error, bad_cert()}. +validate([{path, Path}|Paths], true) -> + case validate_path(Path) of + ok -> + validate(Paths, true); + Err -> + Err + end; +validate(_, _) -> + ok. + +-spec validate_path([cert()]) -> ok | {error, bad_cert()}. +validate_path([Cert|_] = Certs) -> + case find_local_issuer(Cert) of + {ok, IssuerCert} -> + try public_key:pkix_path_validation(IssuerCert, Certs, []) of + {ok, _} -> + ok; + Err -> + Err + catch error:function_clause -> + case erlang:get_stacktrace() of + [{public_key, pkix_sign_types, _, _}|_] -> + {error, {bad_cert, unknown_sig_algo}}; + ST -> + %% Bug in public_key application + erlang:raise(error, function_clause, ST) + end + end; + {error, _} = Err -> + case public_key:pkix_is_self_signed(Cert) of + true -> + {error, {bad_cert, selfsigned_peer}}; + false -> + Err + end + end. + +-spec ca_dir() -> string(). +ca_dir() -> + ejabberd_config:get_option(ca_path, "/etc/ssl/certs"). + +-spec check_ca_dir() -> ok. +check_ca_dir() -> + case filelib:wildcard(filename:join(ca_dir(), "*.0")) of + [] -> + Hint = "configuring 'ca_path' option might help", + case file:list_dir(ca_dir()) of + {error, Why} -> + ?WARNING_MSG("failed to read CA directory ~s: ~s; ~s", + [ca_dir(), file:format_error(Why), Hint]); + {ok, _} -> + ?WARNING_MSG("CA directory ~s doesn't contain " + "hashed certificate files; ~s", + [ca_dir(), Hint]) + end; + _ -> + ok + end. + +-spec find_local_issuer(cert()) -> {ok, cert()} | {error, {bad_cert, unknown_ca}}. +find_local_issuer(Cert) -> + {ok, {_, IssuerID}} = public_key:pkix_issuer_id(Cert, self), + Hash = public_key:short_name_hash(IssuerID), + filelib:fold_files( + ca_dir(), Hash ++ "\\.[0-9]+", false, + fun(_, {ok, IssuerCert}) -> + {ok, IssuerCert}; + (CertFile, Acc) -> + try + {ok, Data} = file:read_file(CertFile), + {ok, [IssuerCert|_], _} = pem_decode(Data), + case public_key:pkix_is_issuer(Cert, IssuerCert) of + true -> + {ok, IssuerCert}; + false -> + Acc + end + catch _:{badmatch, {error, Why}} -> + ?ERROR_MSG("failed to read CA certificate from \"~s\": ~s", + [CertFile, format_error(Why)]), + Acc + end + end, {error, {bad_cert, unknown_ca}}). + +-spec match_cert_keys([{path, [cert()]}], [priv_key()]) + -> {ok, [{cert(), priv_key()}]} | {error, {bad_cert, missing_priv_key}}. +match_cert_keys(CertPaths, PrivKeys) -> + KeyPairs = [{pubkey_from_privkey(PrivKey), PrivKey} || PrivKey <- PrivKeys], + match_cert_keys(CertPaths, KeyPairs, []). + +-spec match_cert_keys([{path, [cert()]}], [{pub_key(), priv_key()}], + [{cert(), priv_key()}]) + -> {ok, [{cert(), priv_key()}]} | {error, {bad_cert, missing_priv_key}}. +match_cert_keys([{path, Certs}|CertPaths], KeyPairs, Result) -> + [Cert|_] = RevCerts = lists:reverse(Certs), + PubKey = pubkey_from_cert(Cert), + case lists:keyfind(PubKey, 1, KeyPairs) of + false -> + {error, {bad_cert, missing_priv_key}}; + {_, PrivKey} -> + match_cert_keys(CertPaths, KeyPairs, [{RevCerts, PrivKey}|Result]) + end; +match_cert_keys([], _, Result) -> + {ok, Result}. + +-spec pubkey_from_cert(cert()) -> pub_key(). +pubkey_from_cert(Cert) -> + TBSCert = Cert#'OTPCertificate'.tbsCertificate, + PubKeyInfo = TBSCert#'OTPTBSCertificate'.subjectPublicKeyInfo, + SubjPubKey = PubKeyInfo#'OTPSubjectPublicKeyInfo'.subjectPublicKey, + case PubKeyInfo#'OTPSubjectPublicKeyInfo'.algorithm of + #'PublicKeyAlgorithm'{ + algorithm = ?rsaEncryption} -> + SubjPubKey; + #'PublicKeyAlgorithm'{ + algorithm = ?'id-dsa', + parameters = {params, DSSParams}} -> + {SubjPubKey, DSSParams}; + #'PublicKeyAlgorithm'{ + algorithm = ?'id-ecPublicKey'} -> + SubjPubKey + end. + +-spec pubkey_from_privkey(priv_key()) -> pub_key(). +pubkey_from_privkey(#'RSAPrivateKey'{modulus = Modulus, + publicExponent = Exp}) -> + #'RSAPublicKey'{modulus = Modulus, + publicExponent = Exp}; +pubkey_from_privkey(#'DSAPrivateKey'{p = P, q = Q, g = G, y = Y}) -> + {Y, #'Dss-Parms'{p = P, q = Q, g = G}}; +pubkey_from_privkey(#'ECPrivateKey'{publicKey = Key}) -> + #'ECPoint'{point = Key}. + +-spec get_domains([{path, [cert()]}]) -> [binary()]. +get_domains(CertPaths) -> + lists:usort( + lists:flatmap( + fun({path, Certs}) -> + Cert = lists:last(Certs), + xmpp_stream_pkix:get_cert_domains(Cert) + end, CertPaths)). + +-spec get_cert_paths([cert()]) -> [{path, [cert()]}]. +get_cert_paths(Certs) -> + G = digraph:new([acyclic]), + lists:foreach( + fun(Cert) -> + digraph:add_vertex(G, Cert) + end, Certs), + lists:foreach( + fun({Cert1, Cert2}) when Cert1 /= Cert2 -> + case public_key:pkix_is_issuer(Cert1, Cert2) of + true -> + digraph:add_edge(G, Cert1, Cert2); + false -> + ok + end; + (_) -> + ok + end, [{Cert1, Cert2} || Cert1 <- Certs, Cert2 <- Certs]), + Paths = lists:flatmap( + fun(Cert) -> + case digraph:in_degree(G, Cert) of + 0 -> + get_cert_path(G, [Cert]); + _ -> + [] + end + end, Certs), + digraph:delete(G), + Paths. + +get_cert_path(G, [Root|_] = Acc) -> + case digraph:out_edges(G, Root) of + [] -> + [{path, Acc}]; + Es -> + lists:flatmap( + fun(E) -> + {_, _, V, _} = digraph:edge(G, E), + get_cert_path(G, [V|Acc]) + end, Es) + end. diff --git a/src/ejabberd_router.erl b/src/ejabberd_router.erl index 844651329..69413c6de 100644 --- a/src/ejabberd_router.erl +++ b/src/ejabberd_router.erl @@ -157,6 +157,7 @@ register_route(Domain, ServerHost, LocalHint, Pid) -> get_component_number(LDomain), Pid) of ok -> ?DEBUG("Route registered: ~s", [LDomain]), + ejabberd_hooks:run(route_registered, [LDomain]), delete_cache(Mod, LDomain); {error, Err} -> ?ERROR_MSG("Failed to register route ~s: ~p", @@ -185,6 +186,7 @@ unregister_route(Domain, Pid) -> LDomain, get_component_number(LDomain), Pid) of ok -> ?DEBUG("Route unregistered: ~s", [LDomain]), + ejabberd_hooks:run(route_unregistered, [LDomain]), delete_cache(Mod, LDomain); {error, Err} -> ?ERROR_MSG("Failed to unregister route ~s: ~p", diff --git a/src/ejabberd_s2s_in.erl b/src/ejabberd_s2s_in.erl index c215557c4..d8e124b52 100644 --- a/src/ejabberd_s2s_in.erl +++ b/src/ejabberd_s2s_in.erl @@ -356,7 +356,11 @@ change_shaper(#{shaper := ShaperName, server_host := ServerHost} = State, (max_fsm_queue) -> fun((pos_integer()) -> pos_integer()); (atom()) -> [atom()]. listen_opt_type(shaper) -> fun acl:shaper_rules_validator/1; -listen_opt_type(certfile) -> ejabberd_s2s:opt_type(s2s_certfile); +listen_opt_type(certfile) -> + fun(S) -> + ejabberd_pkix:add_certfile(S), + iolist_to_binary(S) + end; listen_opt_type(ciphers) -> ejabberd_s2s:opt_type(s2s_ciphers); listen_opt_type(dhfile) -> ejabberd_s2s:opt_type(s2s_dhfile); listen_opt_type(cafile) -> ejabberd_s2s:opt_type(s2s_cafile); diff --git a/src/ejabberd_service.erl b/src/ejabberd_service.erl index fff0eca5b..a83ec41f0 100644 --- a/src/ejabberd_service.erl +++ b/src/ejabberd_service.erl @@ -276,7 +276,11 @@ transform_listen_option(Opt, Opts) -> (atom()) -> [atom()]. listen_opt_type(access) -> fun acl:access_rules_validator/1; listen_opt_type(shaper_rule) -> fun acl:shaper_rules_validator/1; -listen_opt_type(certfile) -> fun misc:try_read_file/1; +listen_opt_type(certfile) -> + fun(S) -> + ejabberd_pkix:add_certfile(S), + iolist_to_binary(S) + end; listen_opt_type(ciphers) -> fun misc:try_read_file/1; listen_opt_type(dhfile) -> fun misc:try_read_file/1; listen_opt_type(cafile) -> fun misc:try_read_file/1; diff --git a/src/ejabberd_sip.erl b/src/ejabberd_sip.erl index ee1f33c83..d7404a30e 100644 --- a/src/ejabberd_sip.erl +++ b/src/ejabberd_sip.erl @@ -47,7 +47,10 @@ socket_type() -> raw. listen_opt_type(certfile) -> - fun misc:try_read_file/1; + fun(S) -> + ejabberd_pkix:add_certfile(S), + iolist_to_binary(S) + end; listen_opt_type(tls) -> fun(B) when is_boolean(B) -> B end; listen_opt_type(_) -> diff --git a/src/ejabberd_stun.erl b/src/ejabberd_stun.erl index 7557df598..3611edba7 100644 --- a/src/ejabberd_stun.erl +++ b/src/ejabberd_stun.erl @@ -114,7 +114,10 @@ listen_opt_type(auth_realm) -> listen_opt_type(tls) -> fun(B) when is_boolean(B) -> B end; listen_opt_type(certfile) -> - fun misc:try_read_file/1; + fun(S) -> + ejabberd_pkix:add_certfile(S), + iolist_to_binary(S) + end; listen_opt_type(turn_min_port) -> fun(P) when is_integer(P), P > 0, P =< 65535 -> P end; listen_opt_type(turn_max_port) -> diff --git a/src/ejabberd_sup.erl b/src/ejabberd_sup.erl index 91afbe89d..224ed16c1 100644 --- a/src/ejabberd_sup.erl +++ b/src/ejabberd_sup.erl @@ -148,6 +148,8 @@ init([]) -> permanent, 5000, worker, [ejabberd_admin]}, CyrSASL = {cyrsasl, {cyrsasl, start_link, []}, permanent, 5000, worker, [cyrsasl]}, + PKIX = {ejabberd_pkix, {ejabberd_pkix, start_link, []}, + permanent, 5000, worker, [ejabberd_pkix]}, {ok, {{one_for_one, 10, 1}, [Hooks, CyrSASL, @@ -156,6 +158,7 @@ init([]) -> Ctl, Commands, Admin, + PKIX, Listener, SystemMonitor, S2S,