src/support/z_ssl_certs.erl

%% @author Marc Worrell <marc@worrell.nl>
%% @author Maas-Maarten Zeeman <mmzeeman@xs4all.nl>
%% @copyright 2012-2019 Marc Worrell, Maas-Maarten Zeeman
%% @doc SSL support functions, create self-signed certificates
%% @end

%% Copyright 2012-2019 Marc Worrell, Maas-Maarten Zeeman
%%
%% Licensed under the Apache License, Version 2.0 (the "License");
%% you may not use this file except in compliance with the License.
%% You may obtain a copy of the License at
%%
%%     http://www.apache.org/licenses/LICENSE-2.0
%%
%% Unless required by applicable law or agreed to in writing, software
%% distributed under the License is distributed on an "AS IS" BASIS,
%% WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
%% See the License for the specific language governing permissions and
%% limitations under the License.

-module(z_ssl_certs).
-author('Marc Worrell <marc@worrell.nl>').
-author('Maas-Maarten Zeeman <mmzeeman@xs4all.nl>').

-export([
    ssl_listener_options/0,
    sni_fun/1,

    sign/2,
    get_ssl_options/1,
    get_ssl_options/2,

    sni_self_signed/1,
    ensure_self_signed/1
]).

-include_lib("zotonic.hrl").
-include_lib("public_key/include/public_key.hrl").

-define(BITS, "4096").

%% @doc Return the options to use for non-sni ssl
%% @todo should we use the hostname as configured in the OS for the certs?
-spec ssl_listener_options() -> list(ssl:tls_option()).
ssl_listener_options() ->
    {ok, Hostname} = inet:gethostname(),
    {ok, CertOptions} = ensure_self_signed(Hostname),
    [
        {sni_fun, fun ?MODULE:sni_fun/1}
    ]
    ++ zotonic_ssl_option:get_safe_tls_server_options()
    ++ CertOptions
    ++ z_ssl_dhfile:dh_options().

%% @doc Callback for SSL SNI, match the hostname to a set of keys
-spec sni_fun(string()) -> [ ssl:tls_option() ] | undefined.
sni_fun(Hostname) ->
    NormalizedHostname = normalize_hostname(Hostname),
    NormalizedHostnameBin = z_convert:to_binary(NormalizedHostname),
    case get_site_for_hostname(NormalizedHostnameBin) of
        {ok, Site} ->
            get_ssl_options(NormalizedHostnameBin, z_context:new(Site));
        {error, nosite} ->
            %% @todo Serve the correct cert for sites that are down or disabled
            {ok, LocalHostname} = inet:gethostname(),
            sni_self_signed(LocalHostname)
    end.

-spec sni_self_signed( string() | binary() ) -> list( ssl:tls_option() ) | undefined.
sni_self_signed(Hostname) ->
    HostnameS = z_convert:to_list(Hostname),
    case get_self_signed_files(HostnameS) of
        {ok, SSLOptions} ->
            CertFile = proplists:get_value(certfile, SSLOptions),
            KeyFile = proplists:get_value(keyfile, SSLOptions),
            case filelib:is_file(CertFile) andalso filelib:is_file(KeyFile) of
                true ->
                    SSLOptions;
                false ->
                    case ensure_self_signed(HostnameS) of
                        {ok, SSLOptionsNew} -> SSLOptionsNew;
                        {error, _} -> undefined
                    end
            end;
        {error, no_security_dir} ->
            undefined
    end.

%% @doc Sign data using the current private key and sha256
%% @todo If needed more often then cache the decoded private key.
-spec sign(iodata(), z:context()) -> {ok, binary()} | {error, term()}.
sign(Data, Context) ->
    Hostname = z_context:hostname(Context),
    case get_ssl_options(Hostname, Context) of
        SSLOptions when is_list(SSLOptions) ->
            KeyFile = proplists:get_value(keyfile, SSLOptions),
            {ok, PemKeyBin} = file:read_file(KeyFile),
            [PemKeyData] = public_key:pem_decode(PemKeyBin),
            PemKey = public_key:pem_entry_decode(PemKeyData),
            {ok, public_key:sign(Data, sha256, PemKey)};
        undefined ->
            {error, nocerts}
    end.

%% @doc Find the site or fallback site that will handle the request
-spec get_site_for_hostname( binary() ) -> {ok, atom()} | {error, nosite}.
get_site_for_hostname(Hostname) ->
    case z_sites_dispatcher:get_site_for_hostname(Hostname) of
        {ok, Site} ->
            case z_sites_manager:wait_for_running(Site) of
                ok -> {ok, Site};
                {error, _} -> {error, nosite}
            end;
        undefined ->
            {error, nosite}
    end.

%% @doc Fetch the ssi options for the site context.
-spec get_ssl_options( z:context() | undefined ) -> list( ssl:tls_option() ) | undefined.
get_ssl_options(Context) ->
    get_ssl_options(site_hostname(Context), Context).

%% @doc Fetch the ssl options for the given hostname and site context. If there is
%%      is no module observing ssl_options, then return the self signed certificates.
-spec get_ssl_options( binary(), z:context() ) -> list( ssl:tls_option() ) | undefined.
get_ssl_options(Hostname, Context) when is_binary(Hostname) ->
    case z_notifier:first(#ssl_options{ server_name=Hostname }, Context) of
        {ok, SSLOptions} ->
            SSLOptions;
        undefined ->
            sni_self_signed( z_convert:to_list(Hostname) )
    end.


-spec get_self_signed_files( string() ) -> {ok, list( ssl:tls_option() )} | {error, no_security_dir}.
get_self_signed_files(Hostname) ->
    case z_config_files:security_dir() of
        {ok, SecurityDir} ->
            SSLDir = filename:join(SecurityDir, "self-signed"),
            get_self_signed_files(Hostname, SSLDir);
        {error, _} ->
            {error, no_security_dir}
    end.

%% @doc Fetch the paths of all self-signed certificates
-spec get_self_signed_files( string(), file:filename_all() ) -> {ok, list( ssl:tls_option() )}.
get_self_signed_files( Hostname, SSLDir ) ->
    Options = [
        {certfile, filename:join(SSLDir, Hostname ++ "-self-signed" ++ ?BITS ++ ".crt")},
        {keyfile, filename:join(SSLDir, Hostname ++ "-self-signed" ++ ?BITS ++".pem")}
    ],
    {ok, Options}.

normalize_hostname(Hostname) when is_list(Hostname) ->
    [ to_lower(C) || C <- Hostname, is_valid_hostname_char(C) ].

to_lower(C) when C >= $A, C =< $Z -> C + 32;
to_lower(C) -> C.

is_valid_hostname_char($.) -> true;
is_valid_hostname_char(C) when C >= $a, C =< $z -> true;
is_valid_hostname_char(C) when C >= $A, C =< $Z -> true;
is_valid_hostname_char(C) when C >= $0, C =< $9 -> true;
is_valid_hostname_char($-) -> true;
is_valid_hostname_char(_) -> false.


%% @doc Check if all certificates are available in the site's ssl directory
-spec ensure_self_signed( string() ) ->  {ok, list( ssl:tls_option() )} | {error, term()}.
ensure_self_signed(Hostname) ->
    {ok, Certs} = get_self_signed_files(Hostname),
    CertFile = proplists:get_value(certfile, Certs),
    KeyFile = proplists:get_value(keyfile, Certs),
    case {filelib:is_file(CertFile), filelib:is_file(KeyFile)} of
        {false, false} ->
            generate_self_signed(Hostname, Certs);
        {false, true} ->
            ?LOG_ERROR(#{
                text => <<"Missing cert file, regenerating keys">>,
                in => zotonic_core,
                file => CertFile
            }),
            generate_self_signed(Hostname, Certs);
        {true, false} ->
            ?LOG_ERROR(#{
                text => <<"Missing pem file, regenerating keys">>,
                in => zotonic_core,
                file => KeyFile
            }),
            generate_self_signed(Hostname, Certs);
        {true, true} ->
            case zotonic_ssl_certs:check_keyfile(KeyFile) of
                ok -> {ok, Certs};
                {error, _} = E -> E
            end
    end.

-spec site_hostname(z:context() | undefined) -> binary().
site_hostname(undefined) ->
    {ok, LocalHostname} = inet:gethostname(),
    z_convert:to_binary(LocalHostname);
site_hostname(Context) ->
    case z_context:hostname(Context) of
        <<"none">> ->
            site_hostname(undefined);
        ConfiguredHostname ->
            ConfiguredHostname
    end.

-spec generate_self_signed( string(), proplists:proplist() ) -> {ok, list()} | {error, term()}.
generate_self_signed(Hostname, Opts) ->
    {keyfile, PemFile} = proplists:lookup(keyfile, Opts),
    ?LOG_INFO(#{
        text => <<"Generating self-signed ssl keys">>,
        in => zotonic_core,
        file => PemFile
    }),
    case z_filelib:ensure_dir(PemFile) of
        ok ->
            _ = file:change_mode(filename:dirname(PemFile), 8#00700),
            {certfile, CertFile} = proplists:lookup(certfile, Opts),
            Options = #{
                hostname => Hostname,
                servername => server_name()
            },
            case zotonic_ssl_certs:ensure_self_signed(CertFile, PemFile, Options) of
                ok ->
                    {ok, [
                        {certfile, CertFile},
                        {keyfile, PemFile}
                    ]};
                {error, _} = Error ->
                    Error
            end;
        {error, _} = Error ->
            {error, {ensure_dir, Error, PemFile}}
    end.

%% @doc Return the name of the server, defaults to 'Zotonic' or
%  the alphanumerical part of the server_header config.
-spec server_name() -> binary().
server_name() ->
    case z_convert:to_binary( z_config:get(server_header) ) of
        <<>> -> <<"Zotonic">>;
        Name -> filter_server_name(Name, <<>>)
    end.

-spec filter_server_name(binary(), binary()) -> binary().
filter_server_name(<<>>, Acc) ->
    Acc;
filter_server_name(<<C, Rest/binary>>, Acc) when C >= $a, C =< $z ->
    filter_server_name(Rest, <<Acc/binary, C>>);
filter_server_name(<<C, Rest/binary>>, Acc) when C >= $A, C =< $Z ->
    filter_server_name(Rest, <<Acc/binary, C>>);
filter_server_name(<<C, Rest/binary>>, Acc) when C >= $0, C =< $9 ->
    filter_server_name(Rest, <<Acc/binary, C>>);
filter_server_name(<<32, Rest/binary>>, Acc) ->
    filter_server_name(Rest, <<Acc/binary, 32>>);
filter_server_name(<<_, Rest/binary>>, Acc) ->
    filter_server_name(Rest, Acc).