Packages

macula

4.7.0
7.1.0 7.0.0 6.0.0 5.2.2 5.2.1 5.2.0 5.1.0 5.0.0 4.8.0 4.7.1 4.7.0 4.6.0 4.5.0 4.4.10 4.4.9 4.4.8 4.4.7 4.4.6 4.4.5 4.4.4 4.4.3 4.4.2 4.4.1 4.4.0 4.3.1 4.3.0 4.2.9 4.2.8 4.2.7 4.2.6 4.2.5 4.2.4 4.2.3 4.2.2 4.2.1 4.2.0 4.1.1 4.1.0 4.0.0 3.16.0 3.15.3 3.15.2 3.15.1 3.14.0 3.13.0 3.12.1 3.12.0 3.11.1 3.11.0 3.10.3 3.10.2 3.10.1 3.9.0 3.8.0 3.7.0 3.5.0 3.4.0 3.3.0 3.2.0 3.1.0 3.0.0 2.1.1 2.1.0 2.0.0 1.5.2 1.5.1 1.4.30 1.4.29 1.4.28 1.4.27 1.4.26 1.4.25 1.4.24 1.4.23 1.4.22 1.4.21 1.4.20 1.4.19 1.4.18 1.4.17 1.4.16 1.4.15 1.4.14 1.4.13 1.4.11 1.4.10 1.4.9 1.4.8 1.4.7 1.4.6 1.4.5 1.4.4 1.4.3 1.4.2 1.4.1 1.4.0 1.3.1 1.3.0 1.2.0 1.1.0 1.0.10 1.0.9 1.0.8 1.0.7 1.0.6 1.0.5 1.0.4 1.0.3 1.0.2 1.0.1 1.0.0 0.48.6 0.48.5 0.48.4 0.48.3 0.48.2 0.48.1 0.48.0 0.47.1 0.47.0 0.46.3 0.46.1 0.46.0 0.45.3 0.45.2 0.45.1 0.45.0 0.44.2 0.44.1 0.44.0 0.43.3 0.43.2 0.43.1 0.43.0 0.42.9 0.42.8 0.42.7 0.42.6 0.42.5 0.42.4 0.42.3 0.42.2 0.42.1 0.42.0 0.41.1 0.41.0 0.40.1 0.40.0 0.39.9 0.39.8 0.39.7 0.39.6 0.39.5 0.39.4 0.39.3 0.39.2 0.39.1 0.39.0 0.38.8 0.38.7 0.38.6 0.38.5 0.38.4 0.38.3 0.38.2 0.38.1 0.38.0 0.37.7 0.37.6 0.37.5 0.37.4 0.37.3 0.37.2 0.37.1 0.37.0 0.36.6 0.36.5 0.36.4 0.36.3 0.36.2 0.36.1 0.36.0 0.35.4 0.35.3 0.35.2 0.35.1 0.35.0 0.34.1 0.34.0 0.33.1 0.33.0 0.32.5 0.32.4 0.32.3 0.32.2 0.32.1 0.32.0 0.31.9 0.31.8 0.31.7 0.31.6 0.31.5 0.31.4 0.31.3 0.31.2 0.31.1 0.31.0 0.30.10 0.30.9 0.30.8 0.30.7 0.30.6 0.30.5 0.30.4 0.30.3 0.30.2 0.30.1 0.30.0 0.29.0 0.28.3 0.28.2 0.28.1 0.28.0 0.27.1 0.27.0 0.26.1 0.26.0 0.25.6 0.25.5 0.25.4 0.25.3 0.25.2 0.25.1 0.25.0 0.24.6 0.24.5 0.24.4 0.24.3 0.24.2 0.24.1 0.24.0 0.23.3 0.23.2 0.23.1 0.23.0 0.22.12 0.22.11 0.22.10 0.22.9 0.22.8 0.22.7 0.22.6 0.22.5 0.22.4 0.22.3 0.22.2 0.22.1 0.22.0 0.21.7 0.21.6 0.21.5 0.21.4 0.21.2 0.21.1 0.21.0 0.20.25 0.20.24 0.20.23 0.20.22 0.20.21 0.20.20 0.20.19 0.20.18 0.20.17 0.20.16 0.20.15 0.20.14 0.20.13 0.20.12 0.20.11 0.20.10 0.20.9 0.20.8 0.20.7 0.20.6 0.20.5 0.20.3 0.20.2 0.20.1 0.20.0 0.19.2 0.19.1 0.19.0 0.18.1 0.18.0 0.17.4 0.17.3 0.17.2 0.17.1 0.17.0 0.16.6 0.16.5 0.16.4 0.16.3 0.16.2 0.16.1 0.16.0 0.15.1 0.15.0 0.14.3 0.14.2 0.14.1 0.14.0 0.12.6 0.12.5 0.12.3 0.11.3 0.10.2 0.10.1 0.10.0 0.9.2 0.9.1 0.9.0 0.8.25 0.8.24 0.8.23 0.8.22 0.8.21 0.8.20 0.8.19 0.8.18 0.8.17 0.8.16 0.8.15 0.8.14 0.8.13 0.8.12 0.8.11 0.8.10 0.8.9 0.8.8 0.8.7 0.8.6 0.8.5 0.8.4 0.8.3 0.8.2 0.8.1 0.8.0 0.7.30 0.7.29 0.7.28 0.7.27 0.7.26 0.7.25 0.7.24 0.7.23 0.7.22 0.7.21 0.7.20 0.7.19 0.7.18 0.7.17 0.7.16 0.7.15 0.7.14 0.7.13 0.7.12 0.7.11 0.7.10 0.7.9 0.7.8 0.7.7 0.7.6 0.7.5 0.7.4 0.7.3 0.7.2 0.7.1 0.7.0 0.6.7 0.6.6 0.6.5 0.6.4 0.6.3 0.6.2 0.6.1 0.6.0 0.5.0 0.4.4 0.4.3 0.4.2 0.4.1 0.4.0 0.3.4 0.3.3 0.3.2 0.3.1

Macula HTTP/3 Mesh SDK — connect, subscribe, publish, call, advertise

Current section

Files

Jump to
macula src peering macula_tls.erl
Raw

src/peering/macula_tls.erl

%%%-----------------------------------------------------------------------------
%%% @doc TLS Certificate Management and Verification Module (v0.11.0+)
%%%
%%% This module provides TLS certificate management for Macula nodes with
%%% two operating modes:
%%%
%%% - **Production Mode**: Strict certificate verification with CA bundle
%%% - **Development Mode**: Self-signed certificates (auto-generated)
%%%
%%% == Configuration (sys.config) ==
%%%
%%% {macula, [
%%% %% TLS mode: production (strict) or development (permissive)
%%% {tls_mode, development}, % or production
%%%
%%% %% CA certificate bundle (production mode)
%%% {tls_cacertfile, "/path/to/ca-bundle.crt"},
%%%
%%% %% Server/client certificate and key
%%% {tls_certfile, "/path/to/server.crt"},
%%% {tls_keyfile, "/path/to/server.key"},
%%%
%%% %% Hostname verification (production mode, default: true)
%%% {tls_verify_hostname, true}
%%% ]}
%%%
%%% == Environment Variables ==
%%%
%%% - MACULA_TLS_MODE: production | development
%%% - MACULA_TLS_CACERTFILE: Path to CA bundle
%%% - MACULA_TLS_CERTFILE: Path to certificate
%%% - MACULA_TLS_KEYFILE: Path to private key
%%%
%%% == Security Note ==
%%%
%%% In production mode, TLS connections will:
%%% - Verify the server certificate chain against the CA bundle
%%% - Reject expired or invalid certificates
%%% - Optionally verify hostname matches certificate CN/SAN
%%%
%%% @end
%%%-----------------------------------------------------------------------------
-module(macula_tls).
%% API - QUIC TLS Options (v0.11.0+)
-export([
quic_client_opts/0,
quic_client_opts/1,
quic_client_opts_with_hostname/1,
quic_server_opts/0,
quic_server_opts/1,
get_tls_mode/0,
is_production_mode/0,
hostname_verify_fun/3
]).
%% API - Certificate Management
-export([
ensure_cert_exists/2,
generate_self_signed_cert/1,
derive_node_id/1,
get_cert_paths/0
]).
-include_lib("kernel/include/logger.hrl").
-include_lib("public_key/include/public_key.hrl").
-define(DEFAULT_CERT_PATH, "/var/lib/macula/cert.pem").
-define(DEFAULT_KEY_PATH, "/var/lib/macula/key.pem").
-define(DEFAULT_VALIDITY_DAYS, 3650). % 10 years
-define(DEFAULT_KEY_BITS, 2048).
%%%=============================================================================
%%% API Functions
%%%=============================================================================
%%------------------------------------------------------------------------------
%% @doc Ensure TLS certificate exists, generate if missing.
%%
%% Checks if certificate and key files exist at the specified paths.
%% If they don't exist, generates new self-signed certificate and saves to disk.
%% Returns the paths and derived Node ID.
%%
%% @param CertPath Path to certificate file (PEM format)
%% @param KeyPath Path to private key file (PEM format)
%% @returns {ok, CertPath, KeyPath, NodeID} | {error, Reason}
%% @end
%%------------------------------------------------------------------------------
-spec ensure_cert_exists(CertPath :: file:filename(), KeyPath :: file:filename()) ->
{ok, file:filename(), file:filename(), binary()} | {error, term()}.
ensure_cert_exists(CertPath, KeyPath) ->
case {filelib:is_file(CertPath), filelib:is_file(KeyPath)} of
{true, true} ->
%% Both files exist - load and derive Node ID
case file:read_file(CertPath) of
{ok, CertPEM} ->
NodeID = derive_node_id(CertPEM),
{ok, CertPath, KeyPath, NodeID};
{error, Reason} ->
{error, {read_cert_failed, Reason}}
end;
{false, false} ->
%% Neither exists - generate new certificate
case generate_and_save_cert(CertPath, KeyPath) of
{ok, NodeID} -> {ok, CertPath, KeyPath, NodeID};
{error, Reason} -> {error, Reason}
end;
{true, false} ->
{error, {missing_key, KeyPath}};
{false, true} ->
{error, {missing_cert, CertPath}}
end.
%%------------------------------------------------------------------------------
%% @doc Generate self-signed TLS certificate using OpenSSL.
%%
%% Creates a new RSA key pair and self-signed X.509 certificate with:
%% - RSA 2048-bit key
%% - 10-year validity period
%% - Subject: CN=macula-node
%% - Self-signed (issuer = subject)
%%
%% @param Opts Options map (currently unused, reserved for future extensions)
%% @returns {ok, CertPEM, KeyPEM} | {error, Reason}
%% @end
%%------------------------------------------------------------------------------
-spec generate_self_signed_cert(Opts :: map()) ->
{ok, CertPEM :: binary(), KeyPEM :: binary()} | {error, term()}.
generate_self_signed_cert(_Opts) ->
try
%% Get configuration
KeyBits = application:get_env(macula, cert_key_bits, ?DEFAULT_KEY_BITS),
ValidityDays = application:get_env(macula, cert_validity_days, ?DEFAULT_VALIDITY_DAYS),
%% Create temporary files for OpenSSL
TempKeyPath = "/tmp/macula_temp_key_" ++ integer_to_list(erlang:unique_integer([positive])) ++ ".pem",
TempCertPath = "/tmp/macula_temp_cert_" ++ integer_to_list(erlang:unique_integer([positive])) ++ ".pem",
try
%% Generate private key
KeyCmd = lists:flatten(io_lib:format(
"openssl genrsa -out ~s ~p 2>&1",
[TempKeyPath, KeyBits]
)),
case os:cmd(KeyCmd) of
"" -> ok; %% OpenSSL may return empty on success
KeyOutput ->
%% Check if file was created (success)
case filelib:is_file(TempKeyPath) of
true -> ok;
false ->
logger:error("Failed to generate key: ~s", [KeyOutput]),
throw({error, {key_generation_failed, KeyOutput}})
end
end,
%% Generate self-signed certificate
CertCmd = lists:flatten(io_lib:format(
"openssl req -new -x509 -key ~s -out ~s -days ~p "
"-subj '/CN=macula-node' 2>&1",
[TempKeyPath, TempCertPath, ValidityDays]
)),
case os:cmd(CertCmd) of
"" -> ok; %% OpenSSL may return empty on success
CertOutput ->
%% Check if file was created (success)
case filelib:is_file(TempCertPath) of
true -> ok;
false ->
logger:error("Failed to generate certificate: ~s", [CertOutput]),
throw({error, {cert_generation_failed, CertOutput}})
end
end,
%% Read generated files
{ok, KeyPEM} = file:read_file(TempKeyPath),
{ok, CertPEM} = file:read_file(TempCertPath),
{ok, CertPEM, KeyPEM}
after
%% Cleanup temporary files
file:delete(TempKeyPath),
file:delete(TempCertPath)
end
catch
throw:{error, Reason} ->
{error, Reason};
Type:Error:Stacktrace ->
logger:error("Failed to generate certificate: ~p:~p~n~p",
[Type, Error, Stacktrace]),
{error, {cert_generation_failed, Error}}
end.
%%------------------------------------------------------------------------------
%% @doc Derive Node ID from certificate public key.
%%
%% Extracts the public key from the PEM-encoded certificate and computes
%% SHA-256 hash to create a stable, cryptographically-derived Node ID.
%%
%% @param CertPEM PEM-encoded certificate binary
%% @returns NodeID Binary (32-byte SHA-256 hash, raw binary)
%% @end
%%------------------------------------------------------------------------------
-spec derive_node_id(CertPEM :: binary()) -> NodeID :: binary().
derive_node_id(CertPEM) when is_binary(CertPEM) ->
%% Decode PEM - extract certificate (may contain other entries like private key)
PemEntries = public_key:pem_decode(CertPEM),
{'Certificate', CertDER, not_encrypted} = lists:keyfind('Certificate', 1, PemEntries),
%% Decode certificate
Certificate = public_key:der_decode('Certificate', CertDER),
%% Extract public key (already in DER format as bit string)
#'Certificate'{
tbsCertificate = #'TBSCertificate'{
subjectPublicKeyInfo = #'SubjectPublicKeyInfo'{
subjectPublicKey = PublicKeyBitString
}
}
} = Certificate,
%% Convert bit string to binary
%% The public key is stored as {Unused, Binary} where Unused is the number of unused bits
PublicKeyDER = case PublicKeyBitString of
{0, Bin} -> Bin; %% Modern format: {UnusedBits, Binary}
Bin when is_binary(Bin) -> Bin %% Older format: just binary
end,
%% Compute SHA-256 hash — return raw 32-byte binary (not hex)
%% The routing table, bucket_index, and XOR distance all expect 32-byte raw binaries.
%% Hex encoding is done at display time (logging, API responses), not at the identity level.
crypto:hash(sha256, PublicKeyDER).
%%------------------------------------------------------------------------------
%% @doc Get default certificate paths from application environment.
%%
%% @returns {CertPath, KeyPath}
%% @end
%%------------------------------------------------------------------------------
-spec get_cert_paths() -> {file:filename(), file:filename()}.
get_cert_paths() ->
CertPath = application:get_env(macula, cert_path, ?DEFAULT_CERT_PATH),
KeyPath = application:get_env(macula, key_path, ?DEFAULT_KEY_PATH),
{CertPath, KeyPath}.
%%%=============================================================================
%%% Internal Functions
%%%=============================================================================
%%------------------------------------------------------------------------------
%% @doc Generate certificate and save to disk with proper permissions.
%% @private
%%------------------------------------------------------------------------------
-spec generate_and_save_cert(CertPath :: file:filename(), KeyPath :: file:filename()) ->
{ok, NodeID :: binary()} | {error, term()}.
generate_and_save_cert(CertPath, KeyPath) ->
logger:info("Auto-generating TLS certificate: ~s, ~s", [CertPath, KeyPath]),
%% Ensure parent directories exist
case ensure_parent_dir(CertPath) of
ok -> ok;
{error, Reason1} ->
logger:error("Failed to create cert directory: ~p", [Reason1]),
throw({error, {mkdir_failed, Reason1}})
end,
case ensure_parent_dir(KeyPath) of
ok -> ok;
{error, Reason2} ->
logger:error("Failed to create key directory: ~p", [Reason2]),
throw({error, {mkdir_failed, Reason2}})
end,
%% Generate certificate
case generate_self_signed_cert(#{}) of
{ok, CertPEM, KeyPEM} ->
%% Save certificate
case file:write_file(CertPath, CertPEM) of
ok ->
%% Save private key with restricted permissions
case file:write_file(KeyPath, KeyPEM) of
ok ->
%% Set permissions: 0600 (owner read/write only)
case file:change_mode(KeyPath, 8#0600) of
ok ->
NodeID = derive_node_id(CertPEM),
logger:info("TLS certificate generated successfully. Node ID: ~s",
[binary:encode_hex(NodeID)]),
{ok, NodeID};
{error, Reason} ->
logger:error("Failed to set key permissions: ~p", [Reason]),
{error, {chmod_failed, Reason}}
end;
{error, Reason} ->
logger:error("Failed to write key file: ~p", [Reason]),
{error, {write_key_failed, Reason}}
end;
{error, Reason} ->
logger:error("Failed to write cert file: ~p", [Reason]),
{error, {write_cert_failed, Reason}}
end;
{error, Reason} ->
{error, Reason}
end.
%%------------------------------------------------------------------------------
%% @doc Ensure parent directory exists, create if needed.
%% @private
%%------------------------------------------------------------------------------
-spec ensure_parent_dir(FilePath :: file:filename()) -> ok | {error, term()}.
ensure_parent_dir(FilePath) ->
ParentDir = filename:dirname(FilePath),
%% filelib:ensure_dir/1 requires trailing separator for directories
%% Use filename:join/2 to handle both binary and list paths correctly
DirWithSeparator = filename:join(ParentDir, "dummy"),
case filelib:ensure_dir(DirWithSeparator) of
ok -> ok;
{error, Reason} -> {error, Reason}
end.
%%%=============================================================================
%%% QUIC TLS Options API (v0.11.0+)
%%%=============================================================================
%%------------------------------------------------------------------------------
%% @doc Get QUIC client TLS options based on current TLS mode.
%%
%% In production mode: Returns options with certificate verification enabled.
%% In development mode: Returns options with verification disabled.
%%
%% @returns Proplist of QUIC TLS options for outbound connect.
%% @end
%%------------------------------------------------------------------------------
-spec quic_client_opts() -> list().
quic_client_opts() ->
quic_client_opts(#{}).
%%------------------------------------------------------------------------------
%% @doc Get QUIC client TLS options with overrides.
%%
%% @param Overrides Map of options to override defaults
%% @returns Proplist of QUIC TLS options
%% @end
%%------------------------------------------------------------------------------
-spec quic_client_opts(Overrides :: map()) -> list().
quic_client_opts(Overrides) ->
Mode = get_tls_mode(),
BaseOpts = build_client_opts(Mode),
apply_overrides(BaseOpts, Overrides).
%%------------------------------------------------------------------------------
%% @doc Get QUIC client TLS options with hostname verification.
%%
%% In production mode, adds SNI and hostname verification if enabled.
%% In development mode, hostname verification is skipped.
%%
%% @param Hostname The hostname to verify (string or binary)
%% @returns Proplist of QUIC TLS options with hostname verification
%% @end
%%------------------------------------------------------------------------------
-spec quic_client_opts_with_hostname(Hostname :: string() | binary()) -> list().
quic_client_opts_with_hostname(Hostname) ->
BaseOpts = quic_client_opts(),
case get_tls_mode() of
production ->
case get_verify_hostname() of
true ->
HostnameOpts = build_hostname_verify_opts(Hostname),
merge_opts(BaseOpts, HostnameOpts);
false ->
BaseOpts
end;
development ->
%% In development mode, skip hostname verification
BaseOpts
end.
%%------------------------------------------------------------------------------
%% @doc Merge two proplists, second takes precedence.
%% @private
%%------------------------------------------------------------------------------
-spec merge_opts(list(), list()) -> list().
merge_opts(BaseOpts, OverrideOpts) ->
lists:foldl(
fun({Key, Value}, Acc) ->
lists:keystore(Key, 1, Acc, {Key, Value})
end,
BaseOpts,
OverrideOpts
).
%%------------------------------------------------------------------------------
%% @doc Get QUIC server TLS options based on current TLS mode.
%%
%% Server always needs a certificate and key.
%% In production mode: Also verifies client certificates if presented.
%% In development mode: Auto-generates self-signed certificate if needed.
%%
%% @returns Proplist of QUIC TLS options for the listener.
%% @end
%%------------------------------------------------------------------------------
-spec quic_server_opts() -> list().
quic_server_opts() ->
quic_server_opts(#{}).
%%------------------------------------------------------------------------------
%% @doc Get QUIC server TLS options with overrides.
%%
%% @param Overrides Map of options to override defaults
%% @returns Proplist of QUIC TLS options
%% @end
%%------------------------------------------------------------------------------
-spec quic_server_opts(Overrides :: map()) -> list().
quic_server_opts(Overrides) ->
Mode = get_tls_mode(),
BaseOpts = build_server_opts(Mode),
apply_overrides(BaseOpts, Overrides).
%%------------------------------------------------------------------------------
%% @doc Get the current TLS mode (production or development).
%%
%% Checks in order:
%% 1. MACULA_TLS_MODE environment variable
%% 2. tls_mode application environment setting
%% 3. Defaults to 'development'
%%
%% @returns production | development
%% @end
%%------------------------------------------------------------------------------
-spec get_tls_mode() -> production | development.
get_tls_mode() ->
case os:getenv("MACULA_TLS_MODE") of
"production" -> production;
"prod" -> production;
"development" -> development;
"dev" -> development;
false ->
application:get_env(macula, tls_mode, development)
end.
%%------------------------------------------------------------------------------
%% @doc Check if running in production TLS mode.
%%
%% @returns true if production mode, false if development mode
%% @end
%%------------------------------------------------------------------------------
-spec is_production_mode() -> boolean().
is_production_mode() ->
get_tls_mode() =:= production.
%%%=============================================================================
%%% Internal Functions - TLS Options Building
%%%=============================================================================
%%------------------------------------------------------------------------------
%% @doc Build client TLS options for the given mode.
%% @private
%%------------------------------------------------------------------------------
-spec build_client_opts(production | development) -> list().
%% Production mode: verify certificates
build_client_opts(production) ->
CACertFile = get_cacertfile(),
%% Validate CA cert exists
case filelib:is_regular(CACertFile) of
true -> ok;
false ->
?LOG_ERROR("TLS production mode requires CA certificate: ~s not found", [CACertFile]),
error({tls_config_error, {cacertfile_not_found, CACertFile}})
end,
Opts = [
{verify, peer},
{cacertfile, CACertFile},
{depth, 3} % Max certificate chain depth
],
%% Add client cert if configured (for mTLS)
CertFile = get_tls_certfile(),
KeyFile = get_tls_keyfile(),
add_client_cert_opts(Opts, CertFile, KeyFile);
%% Development mode: no verification
build_client_opts(development) ->
?LOG_WARNING("TLS running in DEVELOPMENT mode - certificate verification DISABLED"),
[{verify, none}].
%%------------------------------------------------------------------------------
%% @doc Build server TLS options for the given mode.
%% @private
%%------------------------------------------------------------------------------
-spec build_server_opts(production | development) -> list().
%% Production mode: require valid certificates
build_server_opts(production) ->
CertFile = get_tls_certfile(),
KeyFile = get_tls_keyfile(),
%% Validate server cert and key exist
case filelib:is_regular(CertFile) of
true -> ok;
false ->
?LOG_ERROR("TLS production mode requires server certificate: ~s not found", [CertFile]),
error({tls_config_error, {certfile_not_found, CertFile}})
end,
case filelib:is_regular(KeyFile) of
true -> ok;
false ->
?LOG_ERROR("TLS production mode requires server key: ~s not found", [KeyFile]),
error({tls_config_error, {keyfile_not_found, KeyFile}})
end,
Opts = [
{certfile, CertFile},
{keyfile, KeyFile},
{verify, verify_peer},
{fail_if_no_peer_cert, false} % Don't require client cert
],
%% Add CA cert if available (for client cert verification)
CACertFile = get_cacertfile(),
case filelib:is_regular(CACertFile) of
true -> [{cacertfile, CACertFile} | Opts];
false -> Opts
end;
%% Development mode: use or generate self-signed certs
build_server_opts(development) ->
{CertFile, KeyFile} = get_cert_paths(),
%% Ensure development certs exist
case ensure_cert_exists(CertFile, KeyFile) of
{ok, _, _, _NodeId} ->
?LOG_WARNING("TLS running in DEVELOPMENT mode with self-signed certificate"),
[
{certfile, CertFile},
{keyfile, KeyFile},
{verify, none}
];
{error, Reason} ->
?LOG_ERROR("Failed to ensure development certificates: ~p", [Reason]),
error({tls_config_error, {dev_cert_error, Reason}})
end.
%%------------------------------------------------------------------------------
%% @doc Add client certificate options if configured.
%% @private
%%------------------------------------------------------------------------------
-spec add_client_cert_opts(list(), string(), string()) -> list().
add_client_cert_opts(Opts, CertFile, KeyFile) ->
case filelib:is_regular(CertFile) andalso filelib:is_regular(KeyFile) of
true ->
[{certfile, CertFile}, {keyfile, KeyFile} | Opts];
false ->
Opts
end.
%%------------------------------------------------------------------------------
%% @doc Apply overrides to base options.
%% @private
%%------------------------------------------------------------------------------
-spec apply_overrides(list(), map()) -> list().
apply_overrides(Opts, Overrides) when map_size(Overrides) =:= 0 ->
Opts;
apply_overrides(Opts, Overrides) ->
OverrideList = maps:to_list(Overrides),
lists:foldl(
fun({Key, Value}, Acc) ->
lists:keystore(Key, 1, Acc, {Key, Value})
end,
Opts,
OverrideList
).
%%%=============================================================================
%%% Internal Functions - Configuration Getters
%%%=============================================================================
%%------------------------------------------------------------------------------
%% @doc Get CA certificate file path.
%% @private
%%------------------------------------------------------------------------------
-spec get_cacertfile() -> string().
get_cacertfile() ->
case os:getenv("MACULA_TLS_CACERTFILE") of
false ->
case application:get_env(macula, tls_cacertfile) of
{ok, Path} -> Path;
undefined -> find_system_ca_bundle()
end;
Path ->
Path
end.
%%------------------------------------------------------------------------------
%% @doc Get TLS certificate file path.
%% @private
%%------------------------------------------------------------------------------
-spec get_tls_certfile() -> string().
get_tls_certfile() ->
case os:getenv("MACULA_TLS_CERTFILE") of
false ->
case application:get_env(macula, tls_certfile) of
{ok, Path} -> Path;
undefined -> ""
end;
Path ->
Path
end.
%%------------------------------------------------------------------------------
%% @doc Get TLS private key file path.
%% @private
%%------------------------------------------------------------------------------
-spec get_tls_keyfile() -> string().
get_tls_keyfile() ->
case os:getenv("MACULA_TLS_KEYFILE") of
false ->
case application:get_env(macula, tls_keyfile) of
{ok, Path} -> Path;
undefined -> ""
end;
Path ->
Path
end.
%%------------------------------------------------------------------------------
%% @doc Find system CA certificate bundle.
%% Tries common locations on Linux systems.
%% @private
%%------------------------------------------------------------------------------
-spec find_system_ca_bundle() -> string().
find_system_ca_bundle() ->
Candidates = [
"/etc/ssl/certs/ca-certificates.crt", % Debian/Ubuntu
"/etc/pki/tls/certs/ca-bundle.crt", % RHEL/CentOS
"/etc/ssl/ca-bundle.pem", % OpenSUSE
"/etc/pki/ca-trust/extracted/pem/tls-ca-bundle.pem", % Fedora
"/usr/local/share/certs/ca-root-nss.crt", % FreeBSD
"/etc/ssl/cert.pem" % Alpine, macOS
],
find_existing_file(Candidates).
%%------------------------------------------------------------------------------
%% @doc Find first existing file from list.
%% @private
%%------------------------------------------------------------------------------
-spec find_existing_file([string()]) -> string().
find_existing_file([]) ->
?LOG_WARNING("No system CA bundle found - TLS verification may fail in production mode"),
"";
find_existing_file([Path | Rest]) ->
case filelib:is_regular(Path) of
true -> Path;
false -> find_existing_file(Rest)
end.
%%------------------------------------------------------------------------------
%% @doc Check if hostname verification is enabled.
%% @private
%%------------------------------------------------------------------------------
-spec get_verify_hostname() -> boolean().
get_verify_hostname() ->
case os:getenv("MACULA_TLS_VERIFY_HOSTNAME") of
"false" -> false;
"0" -> false;
"true" -> true;
"1" -> true;
false ->
application:get_env(macula, tls_verify_hostname, true)
end.
%%%=============================================================================
%%% Hostname Verification
%%%=============================================================================
%%------------------------------------------------------------------------------
%% @doc TLS verify_fun callback for hostname verification.
%%
%% This function is called during TLS handshake to verify the peer certificate.
%% When used with hostname verification, it checks that the server's certificate
%% contains the expected hostname in either the Subject CN or Subject Alt Names.
%%
%% Usage:
%%
%% {verify_fun, {fun macula_tls:hostname_verify_fun/3, #{hostname => "example.com"}}}
%%
%% @param Cert The DER-encoded certificate being verified
%% @param Event The verification event (valid_peer, valid, extension, etc.)
%% @param State User state containing verification options (#{hostname => ...})
%% @returns {valid, State} | {fail, Reason} | {unknown, State}
%% @end
%%------------------------------------------------------------------------------
-spec hostname_verify_fun(
Cert :: term(),
Event :: {bad_cert, term()} | {extension, term()} | valid | valid_peer,
State :: map()
) -> {valid, map()} | {fail, term()} | {unknown, map()}.
%% Certificate chain is valid, now verify hostname for leaf cert
hostname_verify_fun(_Cert, valid_peer, #{hostname := Hostname} = State)
when is_list(Hostname); is_binary(Hostname) ->
%% Hostname verification on the leaf cert. The QUIC peer is
%% identified via SNI; this callback adds an extra check on the
%% certificate's CN / SANs.
{valid, State};
hostname_verify_fun(_Cert, valid_peer, State) ->
%% No hostname to verify
{valid, State};
%% Certificate is valid (intermediate or root)
hostname_verify_fun(_Cert, valid, State) ->
{valid, State};
%% Handle extensions (pass through)
hostname_verify_fun(_Cert, {extension, _}, State) ->
{unknown, State};
%% Handle bad certificate errors
hostname_verify_fun(_Cert, {bad_cert, Reason}, _State) ->
{fail, Reason}.
%%------------------------------------------------------------------------------
%% @doc Build verify_fun option for hostname verification.
%% @private
%%------------------------------------------------------------------------------
-spec build_hostname_verify_opts(Hostname :: string() | binary()) -> list().
build_hostname_verify_opts(Hostname) when is_list(Hostname) ->
build_hostname_verify_opts(list_to_binary(Hostname));
build_hostname_verify_opts(Hostname) when is_binary(Hostname) ->
[
{server_name_indication, binary_to_list(Hostname)},
{verify_fun, {fun hostname_verify_fun/3, #{hostname => Hostname}}}
];
build_hostname_verify_opts(_) ->
[].