Packages
macula
0.8.24
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
Current section
Files
src/macula_tls.erl
%%%-----------------------------------------------------------------------------
%%% @doc TLS Certificate Auto-Generation Module
%%%
%%% This module provides zero-config TLS certificate management for Macula nodes.
%%% Certificates are auto-generated on first boot and persisted to disk for
%%% stable node identity across restarts.
%%%
%%% Key Features:
%%% - Auto-generate self-signed certificates on first boot
%%% - Derive stable Node ID from public key (SHA-256 hash)
%%% - Persist certificates to disk (survives restarts)
%%% - Proper file permissions (0600 for private key)
%%%
%%% @end
%%%-----------------------------------------------------------------------------
-module(macula_tls).
%% API
-export([
ensure_cert_exists/2,
generate_self_signed_cert/1,
derive_node_id/1,
get_cert_paths/0
]).
-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, hex-encoded)
%% @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
Hash = crypto:hash(sha256, PublicKeyDER),
%% Return hex-encoded hash
binary:encode_hex(Hash, lowercase).
%%------------------------------------------------------------------------------
%% @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",
[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.