Packages

macula

0.35.2
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 macula_realm_trust.erl
Raw

src/macula_realm_trust.erl

%%%-----------------------------------------------------------------------------
%%% @doc Realm Trust Management for Hybrid Trust Model
%%%
%%% Implements the Hybrid Trust Model (ADR-001) with three trust levels:
%%% - Level 1: Realm Authentication (API key/token validation)
%%% - Level 2: Certificate Trust (TOFU within authenticated realm)
%%% - Level 3: Optional CA-signed certificates for seed nodes
%%%
%%% This module manages:
%%% - Realm authentication via API keys
%%% - Certificate fingerprint registration and verification
%%% - Trust On First Use (TOFU) pattern
%%% - Fingerprint change detection
%%% - Trust revocation
%%%
%%% @end
%%%-----------------------------------------------------------------------------
-module(macula_realm_trust).
%% Public API
-export([
authenticate/2,
register_fingerprint/3,
verify_fingerprint/3,
get_fingerprint_info/2,
get_trusted_peers/1,
revoke_trust/2,
extract_fingerprint/1
]).
%% Rate Limiting API
-export([
init_rate_limiter/0,
get_auth_attempt_count/1,
reset_rate_limit/1
]).
%% Configuration
-define(MAX_AUTH_ATTEMPTS, 5).
-define(RATE_LIMIT_WINDOW_MS, 60000). % 1 minute
%% Types
-export_type([
realm/0,
node_id/0,
fingerprint/0,
session/0
]).
-type realm() :: binary().
-type node_id() :: binary().
-type fingerprint() :: binary().
-type session() :: #{
realm := realm(),
session_token := binary(),
authenticated_at := integer()
}.
-include_lib("public_key/include/public_key.hrl").
%%%=============================================================================
%%% Realm Authentication (Level 1)
%%%=============================================================================
%% @doc Authenticate to a realm using API key
%% Returns a session map on success, error tuple on failure
%% Implements rate limiting per realm
-spec authenticate(realm(), binary()) -> {ok, session()} | {error, term()}.
authenticate(_Realm, <<>>) ->
{error, invalid_credentials};
authenticate(Realm, ApiKey) when is_binary(Realm), is_binary(ApiKey) ->
case check_rate_limit(Realm) of
ok ->
do_authenticate(Realm, ApiKey);
{error, rate_limited} = Error ->
Error
end.
do_authenticate(Realm, ApiKey) ->
case get_realm_secret(Realm) of
{ok, ExpectedKey} ->
verify_api_key(Realm, ApiKey, ExpectedKey);
{error, not_found} ->
record_auth_attempt(Realm, failed),
audit_log(Realm, auth_failure, #{reason => unknown_realm}),
{error, unknown_realm}
end.
verify_api_key(Realm, ApiKey, ApiKey) ->
%% Matching keys - create session
Session = #{
realm => Realm,
session_token => generate_session_token(),
authenticated_at => erlang:system_time(millisecond)
},
reset_auth_attempts(Realm),
audit_log(Realm, auth_success, #{}),
{ok, Session};
verify_api_key(Realm, _ApiKey, _ExpectedKey) ->
record_auth_attempt(Realm, failed),
audit_log(Realm, auth_failure, #{reason => invalid_credentials}),
{error, invalid_credentials}.
%% Get realm secret from storage (mock_dht for testing, DHT for production)
get_realm_secret(Realm) ->
Key = {realm_secret, Realm},
case ets:whereis(mock_dht) of
undefined ->
%% Production: use DHT lookup
get_realm_secret_from_dht(Realm);
_Tid ->
%% Test: use mock DHT
case ets:lookup(mock_dht, Key) of
[{Key, Secret}] -> {ok, Secret};
[] -> {error, not_found}
end
end.
get_realm_secret_from_dht(Realm) ->
%% Use bootstrap_registry for realm secret lookup in production
Key = make_dht_key(realm_secret, Realm),
case whereis(macula_bootstrap_registry) of
undefined ->
{error, not_found};
_ ->
macula_bootstrap_registry:lookup(Key)
end.
generate_session_token() ->
Bytes = crypto:strong_rand_bytes(32),
base64:encode(Bytes).
%%%=============================================================================
%%% Fingerprint Registration (Level 2)
%%%=============================================================================
%% @doc Register a certificate fingerprint for a node in a realm
-spec register_fingerprint(realm(), node_id(), fingerprint()) -> {ok, registered}.
register_fingerprint(Realm, NodeId, Fingerprint)
when is_binary(Realm), is_binary(NodeId), is_binary(Fingerprint) ->
Now = erlang:system_time(millisecond),
Info = #{
fingerprint => Fingerprint,
first_seen => Now,
last_seen => Now,
cert_type => self_signed
},
store_fingerprint(Realm, NodeId, Info),
{ok, registered}.
store_fingerprint(Realm, NodeId, Info) ->
Key = fingerprint_key(Realm, NodeId),
case ets:whereis(mock_dht) of
undefined ->
%% Production: store in DHT
store_fingerprint_in_dht(Key, Info);
_Tid ->
%% Test: store in mock DHT
ets:insert(mock_dht, {Key, Info})
end.
store_fingerprint_in_dht(Key, Info) ->
%% Use bootstrap_registry for fingerprint storage in production
case whereis(macula_bootstrap_registry) of
undefined ->
ok;
_ ->
%% Convert tuple key to binary for DHT storage
BinaryKey = term_to_binary(Key),
macula_bootstrap_registry:store(BinaryKey, Info)
end.
fingerprint_key(Realm, NodeId) ->
{realm_fingerprint, Realm, NodeId}.
%% @private Create a DHT key from type and identifier
make_dht_key(Type, Identifier) ->
term_to_binary({Type, Identifier}).
%%%=============================================================================
%%% Fingerprint Verification (TOFU)
%%%=============================================================================
%% @doc Verify a certificate fingerprint for a node in a realm
%% Implements Trust On First Use (TOFU) - first connection is automatically trusted
-spec verify_fingerprint(realm(), node_id(), fingerprint()) ->
{ok, trusted | trusted_first_use} |
{error, {fingerprint_mismatch, #{expected := fingerprint(), received := fingerprint()}}}.
verify_fingerprint(Realm, NodeId, Fingerprint)
when is_binary(Realm), is_binary(NodeId), is_binary(Fingerprint) ->
case get_fingerprint_info(Realm, NodeId) of
{ok, Info} ->
do_verify_fingerprint(Realm, NodeId, Fingerprint, Info);
{error, not_found} ->
%% TOFU: First connection, trust and register
{ok, registered} = register_fingerprint(Realm, NodeId, Fingerprint),
audit_log(Realm, tofu_trust, #{node_id => NodeId}),
{ok, trusted_first_use}
end.
do_verify_fingerprint(Realm, NodeId, Fingerprint, #{fingerprint := StoredFingerprint})
when Fingerprint =:= StoredFingerprint ->
%% Matching fingerprint - update last_seen and return trusted
update_last_seen(Realm, NodeId),
{ok, trusted};
do_verify_fingerprint(Realm, NodeId, Fingerprint, #{fingerprint := StoredFingerprint}) ->
%% Fingerprint mismatch - possible MITM attack!
audit_log(Realm, fingerprint_mismatch, #{
node_id => NodeId,
expected => StoredFingerprint,
received => Fingerprint
}),
{error, {fingerprint_mismatch, #{
expected => StoredFingerprint,
received => Fingerprint
}}}.
update_last_seen(Realm, NodeId) ->
Key = fingerprint_key(Realm, NodeId),
Now = erlang:system_time(millisecond),
case ets:whereis(mock_dht) of
undefined ->
update_last_seen_in_dht(Key, Now);
_Tid ->
case ets:lookup(mock_dht, Key) of
[{Key, Info}] ->
UpdatedInfo = Info#{last_seen => Now},
ets:insert(mock_dht, {Key, UpdatedInfo});
[] ->
ok
end
end.
update_last_seen_in_dht(Key, Now) ->
%% Update last_seen in bootstrap_registry
case whereis(macula_bootstrap_registry) of
undefined ->
ok;
_ ->
BinaryKey = term_to_binary(Key),
case macula_bootstrap_registry:lookup(BinaryKey) of
{ok, Info} ->
UpdatedInfo = Info#{last_seen => Now},
macula_bootstrap_registry:store(BinaryKey, UpdatedInfo);
{error, not_found} ->
ok
end
end.
%%%=============================================================================
%%% Fingerprint Info Retrieval
%%%=============================================================================
%% @doc Get fingerprint info for a node in a realm
-spec get_fingerprint_info(realm(), node_id()) -> {ok, map()} | {error, not_found}.
get_fingerprint_info(Realm, NodeId) when is_binary(Realm), is_binary(NodeId) ->
Key = fingerprint_key(Realm, NodeId),
case ets:whereis(mock_dht) of
undefined ->
get_fingerprint_from_dht(Key);
_Tid ->
case ets:lookup(mock_dht, Key) of
[{Key, Info}] -> {ok, Info};
[] -> {error, not_found}
end
end.
get_fingerprint_from_dht(Key) ->
%% Retrieve fingerprint from bootstrap_registry
case whereis(macula_bootstrap_registry) of
undefined ->
{error, not_found};
_ ->
BinaryKey = term_to_binary(Key),
macula_bootstrap_registry:lookup(BinaryKey)
end.
%%%=============================================================================
%%% Trusted Peers Query
%%%=============================================================================
%% @doc Get all trusted peers in a realm
-spec get_trusted_peers(realm()) -> {ok, [map()]}.
get_trusted_peers(Realm) when is_binary(Realm) ->
case ets:whereis(mock_dht) of
undefined ->
get_trusted_peers_from_dht(Realm);
_Tid ->
Peers = get_peers_from_mock_dht(Realm),
{ok, Peers}
end.
get_peers_from_mock_dht(Realm) ->
Pattern = {{realm_fingerprint, Realm, '_'}, '_'},
Matches = ets:match_object(mock_dht, Pattern),
[#{node_id => NodeId, fingerprint => maps:get(fingerprint, Info)}
|| {{realm_fingerprint, R, NodeId}, Info} <- Matches, R =:= Realm].
get_trusted_peers_from_dht(Realm) ->
%% Query all fingerprints for a realm from DHT
%% This requires scanning all keys and filtering by realm prefix
case whereis(macula_routing_server) of
undefined ->
{ok, []};
RoutingServerPid ->
case macula_routing_server:get_all_keys(RoutingServerPid) of
{ok, Keys} ->
Peers = filter_realm_peers(Realm, Keys),
{ok, Peers};
_Error ->
{ok, []}
end
end.
filter_realm_peers(Realm, Keys) ->
%% Filter keys that are fingerprint keys for this realm
lists:filtermap(
fun(BinaryKey) ->
filter_and_lookup_peer(Realm, BinaryKey)
end,
Keys
).
filter_and_lookup_peer(Realm, BinaryKey) ->
handle_binary_term_conversion(catch binary_to_term(BinaryKey), Realm, BinaryKey).
%% @private Conversion failed
handle_binary_term_conversion({'EXIT', _}, _Realm, _BinaryKey) ->
false;
%% @private Conversion succeeded - check for realm match
handle_binary_term_conversion({realm_fingerprint, Realm, NodeId}, Realm, BinaryKey) ->
lookup_peer_info(macula_bootstrap_registry:lookup(BinaryKey), NodeId);
%% @private Key doesn't match expected pattern or realm
handle_binary_term_conversion(_, _Realm, _BinaryKey) ->
false.
%% @private Lookup succeeded
lookup_peer_info({ok, Info}, NodeId) ->
{true, #{node_id => NodeId, fingerprint => maps:get(fingerprint, Info)}};
%% @private Lookup failed
lookup_peer_info(_, _NodeId) ->
false.
%%%=============================================================================
%%% Trust Revocation
%%%=============================================================================
%% @doc Revoke trust for a node in a realm
-spec revoke_trust(realm(), node_id()) -> {ok, revoked | not_found}.
revoke_trust(Realm, NodeId) when is_binary(Realm), is_binary(NodeId) ->
Key = fingerprint_key(Realm, NodeId),
Result = case ets:whereis(mock_dht) of
undefined ->
revoke_trust_in_dht(Key);
_Tid ->
case ets:lookup(mock_dht, Key) of
[{Key, _Info}] ->
ets:delete(mock_dht, Key),
{ok, revoked};
[] ->
{ok, not_found}
end
end,
case Result of
{ok, revoked} ->
audit_log(Realm, trust_revoked, #{node_id => NodeId});
_ ->
ok
end,
Result.
revoke_trust_in_dht(Key) ->
%% Delete fingerprint from bootstrap_registry
case whereis(macula_bootstrap_registry) of
undefined ->
{ok, not_found};
_ ->
BinaryKey = term_to_binary(Key),
case macula_bootstrap_registry:delete(BinaryKey) of
ok ->
{ok, revoked};
{error, not_found} ->
{ok, not_found}
end
end.
%%%=============================================================================
%%% Certificate Fingerprint Extraction
%%%=============================================================================
%% @doc Extract SHA-256 fingerprint from certificate PEM
-spec extract_fingerprint(binary()) -> {ok, fingerprint()} | {error, term()}.
extract_fingerprint(CertPEM) when is_binary(CertPEM) ->
case public_key:pem_decode(CertPEM) of
[{'Certificate', CertDER, not_encrypted}] ->
Hash = crypto:hash(sha256, CertDER),
HexHash = binary_to_hex(Hash),
Fingerprint = <<"sha256:", HexHash/binary>>,
{ok, Fingerprint};
[] ->
{error, invalid_pem};
_ ->
{error, unexpected_pem_format}
end.
binary_to_hex(Bin) when is_binary(Bin) ->
list_to_binary([io_lib:format("~2.16.0b", [Byte]) || <<Byte>> <= Bin]).
%%%=============================================================================
%%% Rate Limiting
%%%=============================================================================
%% @doc Initialize the rate limiter ETS table
-spec init_rate_limiter() -> ok.
init_rate_limiter() ->
case ets:whereis(realm_rate_limit) of
undefined ->
ets:new(realm_rate_limit, [named_table, public, set]);
_ ->
ok
end,
ok.
%% @doc Get the current auth attempt count for a realm
-spec get_auth_attempt_count(realm()) -> {ok, non_neg_integer()}.
get_auth_attempt_count(Realm) ->
case ets:whereis(realm_rate_limit) of
undefined ->
{ok, 0};
_Tid ->
case ets:lookup(realm_rate_limit, Realm) of
[{Realm, Count, _Timestamp}] -> {ok, Count};
[] -> {ok, 0}
end
end.
%% @doc Reset the rate limit for a realm
-spec reset_rate_limit(realm()) -> ok.
reset_rate_limit(Realm) ->
case ets:whereis(realm_rate_limit) of
undefined ->
ok;
_Tid ->
ets:delete(realm_rate_limit, Realm),
ok
end.
%% @private Check if a realm is rate limited
-spec check_rate_limit(realm()) -> ok | {error, rate_limited}.
check_rate_limit(Realm) ->
case ets:whereis(realm_rate_limit) of
undefined ->
ok;
_Tid ->
case ets:lookup(realm_rate_limit, Realm) of
[{Realm, Count, Timestamp}] ->
check_rate_limit_window(Count, Timestamp);
[] ->
ok
end
end.
check_rate_limit_window(Count, Timestamp) when Count >= ?MAX_AUTH_ATTEMPTS ->
Now = erlang:system_time(millisecond),
case Now - Timestamp < ?RATE_LIMIT_WINDOW_MS of
true ->
{error, rate_limited};
false ->
ok
end;
check_rate_limit_window(_Count, _Timestamp) ->
ok.
%% @private Record an authentication attempt
-spec record_auth_attempt(realm(), failed | success) -> ok.
record_auth_attempt(Realm, failed) ->
case ets:whereis(realm_rate_limit) of
undefined ->
ok;
_Tid ->
Now = erlang:system_time(millisecond),
case ets:lookup(realm_rate_limit, Realm) of
[{Realm, Count, _OldTimestamp}] ->
ets:insert(realm_rate_limit, {Realm, Count + 1, Now});
[] ->
ets:insert(realm_rate_limit, {Realm, 1, Now})
end,
ok
end;
record_auth_attempt(_Realm, success) ->
ok.
%% @private Reset auth attempts after successful authentication
-spec reset_auth_attempts(realm()) -> ok.
reset_auth_attempts(Realm) ->
reset_rate_limit(Realm).
%%%=============================================================================
%%% Audit Logging
%%%=============================================================================
%% @private Log an audit event
%% Stores events in realm_audit_log ETS table (for testing)
%% In production, would write to external logging system
-spec audit_log(realm(), atom(), map()) -> ok.
audit_log(Realm, EventType, Data) ->
Timestamp = erlang:system_time(millisecond),
Event = {Realm, EventType, Data#{timestamp => Timestamp}},
case ets:whereis(realm_audit_log) of
undefined ->
%% No audit log table - skip (production would use different backend)
ok;
_Tid ->
ets:insert(realm_audit_log, Event),
ok
end.